Slackでのfreee勤怠打刻システムを、デフォルトの連携機能を使わずに実装した理由(設定方法・コード付き)

Slackでのfreee勤怠打刻システムを、デフォルトの連携機能を使わずに実装した理由(設定方法・コード付き)

以前、勤怠の打刻率を改善するために、勤怠ツールとSlackを連携した事例をご紹介しました。

今回、諸事情から勤怠ツールが「freee人事労務(以下freee)」へと変更になりました。

freeeにはSlack連携機能がデフォルトで用意されています。そのため、前回のような開発は不要かと思われましたが、今回もデフォルトの連携機能は利用せず、Slackで発言するだけでfreee打刻ができるシステムを構築して実装しました。

この記事では、なぜデフォルトの連携機能を使わずに実装したのか、アナグラムのテックチームがシステムの導入時に考えていることをお伝えしていきます。また、実装方法もご紹介しているので、導入したいというエンジニアさんはぜひご覧ください。


発話だけで打刻できるシステムを作った理由3つ

なぜわざわざ開発の工数を割いてでもこのような実装をしたのか、理由は大きくわけて3つあり、組織文化と深く結びついているので今回お話していきます。

「たったそれだけのこと」でユーザー体験は大きく変わる

freeeが提供しているSlack Appだと「スラッシュコマンド入力」→「BotがDMで通知」→「ボタンクリック」という3ステップで打刻を行います。

一方今回導入したSlackでの発言では「発言(自動打刻)」という1ステップで完結します。

たった2ステップだけの話ですが、1ステップと3ステップとではユーザー体験は大きく変わります。マーケティングに関わることであれば、たとえばコールトゥアクション(CTA)ボタンの文言であったり、フォームの項目数であったり、小さな変更でユーザー行動が大きく変わることを理解されているかと思います。

「たったそれだけのこと」が使われるか使われないかのユーザー行動を分けるため、本当にその仕組みで使ってもらえるのか、を十分に考えるよう心がけています。

既存の行動パターンへ組み込めないか

freee上であれば、出勤・退勤ボタンを押すだけでは?とも思いますよね。

しかしながら、勤怠ツールを開くという新しい行動パターンを加えると、習慣化するまでには時間が掛かったり、面倒になってその行動自体が習慣化されない可能性が高まります。

たとえば、新しくジムに通おうとした場合、自宅から遠かったり、会社から自宅へ帰る方向とは異なっていると、通うこと自体が面倒になりませんか?そのような場合、会社の帰り道で必ず通る場所のジムを契約すれば、自然と足が向きやすくなりますよね。

機能の導入などにより新たなアクションを取ってもらいたい場合、できるだけそのユーザーの「既存の行動パターン」に組み込めないかを考えています。ジムの例であれば通勤経路、今回の勤怠ツールであれば、普段から仕事で頻繁に利用するSlackがそれに当たります。また、あいさつをするというのも顔を合わせれば日常的に行うため、「おはよう」などを打刻のトリガーにしています。

▼出勤

おはよ,はじめます, 始め, はじめ, 出勤, ハジメ

▼退勤

おつかれ, お疲れ, オツカレ, 退勤, 終わり, オワリ, おわり,オワル,おわる,終わる

頻繁でないアクションやこれまでにない新しい行動を取ってもらうのは、思っている以上に大変です。いつもの慣れた環境や行動の延長線上に自然に組み込めないかは、人間が使う機能の開発を行う上で常に意識したいポイントのひとつです。

Botにもぬくもりを

先日、私の推しバンドのECサイトでグッズを注文したときの自動配信の注文確認メールの最初の文章が下記でした。

「おはようございます!店長の〇〇です」(〇〇はバンドメンバーの名前)

※ちなみにこのバンドは朝だろうが夜だろうが構わずライブ始まりに「おはようございます」と挨拶するバンドです。(購入した時間は20時でした)

たかが自動配信メールなのですが、なんとなく嬉しい気持ちになってしまいました。

このように自動の配信のものであっても人のぬくもりを与えるということは、愛着がわきますし、逆に機械的一辺倒だと冷たい印象を受けてしまうものです。

そんな考えから打刻Botにはアナグラムの公式キャラクター(?)であるグラムくんが喋るようにしています。文章は自由にカスタマイズできます。これもカスタマイズして実装する恩恵のひとつですね。

また、頻繁に目にするものなので、慣れて飽きてしまうのを軽減するためにランダム性を付与するという点からおみくじもつけています。

※実は社員が知らない隠し機能もあります。打刻以外の発言でもなにかが起きたり、誕生日になにかが起こったり…!?

実装方法の解説

それでは実際に実装の解説に移っていきます。エンジニア向けの内容です。

全体の流れ

今回使うのは下記4つのツールです。

  • Slack
  • Google Apps Script(以下GAS)
  • Googleスプレッドシート(employee_id管理用)
  • freee人事労務のAPI(を叩くためのfreeeアプリ含む)

4つのツールを上の画像のように連携させて実装します。

GASについては下記記事を参考にしてください。

freeeアプリの設定

まずはfreeeのAPIを叩くためのfreeeアプリを作成します。

下記公式ヘルプページを参考にアプリケーションを作成し、自社の事業所のcompany_id、Client ID、Client Secretを取得してください。

参考:freee API スタートガイド - freee Developers Community

参考:アプリケーションを作成する - freee Developers Community

アプリの作成は管理者権限を持つユーザーが行わないとアプリ作成はできてもAPI呼び出しの時点で失敗します。ここハマりました。

権限設定は上記2つがあれば良いでしょう。

Slackの発言を受け取れるように(Outgoing Webhookの設定)

次に、Slack上での発言をGASで受け取れるようにします。

これには、Outgoing Webhookを設定します。

Slackのアプリケーション追加ページより「Outgoing webhooks」を検索・選択します。

「Slackに追加」を押下します。

「Outgoing Webhook インテグレーションの追加」を押下します。

下記インテグレーションの設定を行います。

  • チャンネル:打刻宣言をしたいチャンネルを設定します。
  • 引き金となる言葉:打刻をする言葉を設定します。複数ある場合はカンマで区切ります。これらの単語で始まる行がある場合に反応します。
  • URL:下記の方法でGASのURLを取得して設定します。

GASのURLを取得

GASの実行URLを取得します。

GASのファイルを開き、右上の「デプロイ」「新しいデプロイ」を選択します。

歯車マークから「ウェブアプリ」を選択します。

「アクセスできるユーザー」を「全員」に変更し、「デプロイ」を選択します。

アクセスの承認を求められるので「アクセスを承認」を押下し、自分のアカウントでログインします。

ウェブアプリのURLをコピーし、先程のOutgoing Webhookの設定画面に貼り付けます。

以上でSlack上の発話を受け取る設定ができました。

スプレッドシートの設定

freeeでAPI経由の打刻をするには、従業員それぞれの固有のIDであるemployee_idが必要です。

参考:人事労務APIリファレンス - freee Developers Community

今回はそのemployee_idとSlackで発言したユーザーを紐づけるためにGoogleスプレッドシートを使います。

上記のようにメールアドレスとSlack ID、freeeのemployee_idを記載したシートを用意します。(Slackのメールアドレスとfreeeのメールアドレスが共通なことが前提です。)

Slackのユーザー一覧情報を取得

Slackユーザー一覧を取得するためにSlack Appを作成します。

Slackにログインした状態で下記ページを開き、右上の「Create New App」を選択。

Slack API: Applications | Slack

「From Scratch」を選択し、App Nameを設定してワークスペースを選択し、「Create App」。

「Oauth & Permissions」を選択。

「Scopes」「User Token Scopes」の「Add an OAuth Scope」から「users:read」と「users:read.email」を選択する。

左メニューの「Install App」から「Install to Workspace」を選択。

「許可する」を選択。

トークンが取得できるのでコピーしておく。

OAuth認証のライブラリ追加

続いてGASの設定です。

freeeの認証にはOAuth認証を使うため、GASへライブラリ追加が必要です。

GASを開いて「+」を押下。

スクリプトID「1B7FSrk5Zi6L1rSxxTDgDEUsPzlukDsi4KGuTMorsTQHhGBzBkMun4iDF」を入力して検索、追加。

続いてコードに移ります。

// スプレッドシートのURL
const SPREAD_SHEET_URL = "スプレッドシートのURL";

//シート名
const SHEET_NAME = "シート名";

// 従業員情報取得APIのURL
const GET_EMPLOYEES_APIURL = 'https://api.freee.co.jp/hr/api/v1/companies/{COMPANY_ID}/employees?company_id={COMPANY_ID}&limit=100&offset={offset}'

// 従業員情報取得APIの取得上限
const GET_EMPLOYEES_API_LIMIT = 100;

// 自社のcompany_id
const COMPANY_ID = "company_idを記載"

// Slackのトークン
const SLACK_APP_TOKEN = "トークンを記載";

// スプレッドシートオブジェクト
var spreadSheet = SpreadsheetApp.openByUrl(SPREAD_SHEET_URL);

// freee情報
const CLIENT_ID = 'CLIENT_IDを記載';
const CLIENT_SECRET = 'CLIENT_SECRETを記載';


// シートオブジェクト
var sheet = spreadSheet.getSheetByName(SHEET_NAME);

function setSlackFreeeId() {

    var options = {
        "method": "get",
        "contentType": "application/x-www-form-urlencoded",
        "payload": {
            "token": SLACK_APP_TOKEN
        }
    };

    var url = "https://slack.com/api/users.list";
    var response = UrlFetchApp.fetch(url, options);

    var members = JSON.parse(response).members;

    let sheetEmailArray = sheet.getRange('A:A').getValues().flat().filter(String);

    // A列の空いている行
    var nextRow = sheetEmailArray.length + 1;

    var flag = false;
    // A列にあるメールアドレスを検索し、ないものだけスプレッドシートの最後の行に追加
    for (var member of members) {
        let email = member.profile.email;
        // 削除済みユーザー・ボット・自社ドメイン以外を除外
        if (!member.deleted && !member.is_bot && member.id !== "USLACKBOT" && email.includes('自社ドメイン')) {
            // スプレッドシートにないものだけスプレッドシートの最後列に追加
            if (!sheetEmailArray.includes(email)) {
                flag = true;
                let id = member.id;
                let realName = member.real_name.substring(0, member.real_name.indexOf(" /")).replace(/\s+/g, ''); // スペースと/以降を削除
                sheet.getRange(nextRow, 1).setValue(email);
                sheet.getRange(nextRow, 2).setValue(id);
                sheet.getRange(nextRow, 3).setValue(realName);
                nextRow++;
            }
        }
    }
    // 追加あったときのみfreeeのEmployee_idを設定
    if (flag) {
        // シート更新
        SpreadsheetApp.flush();
        setEmployeeId();
    }
}

// スプレッドシートにemployee_idを登録する トリガーで一週間に一度実行
function setEmployeeId() {
    const accessToken = getService().getAccessToken();


    var options = {
        'method': 'get',
        'contentType': 'application/json',
        'headers': {
            'Authorization': 'Bearer ' + accessToken
        }
    };

    // 新しい Map を作成する
    var emailUserIdMap = new Map();

    let offset = 0;

    while (true) {
        var requestUrl = GET_EMPLOYEES_APIURL.replace('{offset}', offset.toString());
        requestUrl = requestUrl.split('{COMPANY_ID}').join(COMPANY_ID);
        var response = JSON.parse(UrlFetchApp.fetch(requestUrl, options));

        // レスポンスが空のJSONである場合、ループを終了する
        if (response.length === 0) {
            break;
        }

        // JSON データを反復処理し、Map に追加する
        for (var i = 0; i < response.length; i++) {
            var user = response[i];

            emailUserIdMap.set(user.email, user.id);
        }

        // オフセットを更新
        offset += GET_EMPLOYEES_API_LIMIT;
    }

    // シートのデータを取得
    var data = sheet.getDataRange().getValues();

    // A列にあるメールアドレスを検索し、対応するuser_idをE列に記載する
    for (var i = 1; i < data.length; i++) {
        var email = data[i][0];  // A列のメールアドレス
        var userId = emailUserIdMap.get(email);  // Mapからuser_idを取得

        if (userId !== undefined) {
            sheet.getRange(i + 1, 4).setValue(userId);  // D列にuser_idを記載
        }
    }
}

// freeeのサービス取得
function getService() {
    return OAuth2.createService('freee')
        .setAuthorizationBaseUrl('https://accounts.secure.freee.co.jp/public_api/authorize')
        .setTokenUrl('https://accounts.secure.freee.co.jp/public_api/token')
        .setClientId(CLIENT_ID)
        .setClientSecret(CLIENT_SECRET)
        .setCallbackFunction('authCallback')
        .setPropertyStore(PropertiesService.getUserProperties())
}

上記コードで各定数を自社のものにして設定。

setSlackFreeeId関数を実行すると、スプレッドシートにSlackのユーザー一覧とfreeeのemployee_idが設定されます。

新入社員の入社などでSlack・freeeのユーザーが追加されるとスプレッドシート更新が必要になるため、GASのトリガーで定期的に実行するように設定すると良いでしょう。

スプレッドシートからemployee_idを取得する

発話ユーザー情報を元にスプレッドシートからfreeeのemployee_idを取得する関数を実装します。

// SlackのユーザーIDからfreeeのemployee_idを取得する
function getFreeeEmployeeId(slackUserId) {

    // key=SlackのユーザーID、value=freeeのemployee_idのマップ
    var slackUserIdEmployeeIdMap = new Map();

    // スプレッドシートの情報をマップに追加
    for (i = 2; i <= sheet.getLastRow(); i++) {
        var userId = sheet.getRange(i, 2).getValue();
        var employeeId = sheet.getRange(i, 4).getValue();

        slackUserIdEmployeeIdMap.set(userId, employeeId);
    }

    // 発話ユーザーのemployee_idを特定
    var speechUserEmployeeId = slackUserIdEmployeeIdMap.get(slackUserId);

    return speechUserEmployeeId;
}

Slackにメッセージを送れるように

次にSlackにメッセージを送る実装をしていきます。

Slackにメッセージを送るにはIncoming WebHooksを使います。

Slackのアプリケーション追加ページより「incoming webhooks」を検索・選択します。

「Slackに追加」を押下します。

投稿するチャンネルを選択し、「Incoming Webhook インテグレーションの追加」を押下します。

エンドポイントのURLが発行されます。これは後で使うのでコピーしておきましょう。(このURLを他人に知られると誰でもSlackに投稿できてしまうので他人に知られないようにしましょう。)

ちなみにですが、下にスクロールすると投稿するBotの名前やアイコンをカスタマイズできます。こちらも親近感のあるものを設定しておくのがおすすめです。弊社ではグラムくんというキャラクターをアイコンにしています。

打刻する

続いて打刻機能の実装です。

// 打刻用APIのURL
const STAMP_APIURL = "https://api.freee.co.jp/hr/api/v1/employees/{employee_id}/time_clocks"


// 出勤 引数はemployee_id、戻値は結果オブジェクト
function startWorking(employeeId) {
    const accessToken = getService().getAccessToken();
    const apiURL = STAMP_APIURL.replace("{employee_id}", employeeId);

    var payload = {
        "company_id": COMPANY_ID,
        "type": "clock_in"
    };

    var options = {
        'method': 'post',
        'contentType': 'application/json',
        'headers': {
            'Authorization': 'Bearer ' + accessToken
        },
        'payload': JSON.stringify(payload)
    };

    // POST実行
    var result = UrlFetchApp.fetch(apiURL, options);

    Logger.log(result);

    return result;
}

上記関数で出勤の打刻が出来ます。

payloadの「type」を「clock_out」に変えるだけで退勤に変えられますので、こちらもfinishWorking関数として実装しておきましょう。

doPost関数の実装

これまで見てきたものを、GASのウェブアプリとして呼び出されるdoPost関数に実装します。適宜エラーハンドリングを追加します。

// 出勤検知ワード
const ATTENDANCE_WORDS = ["はじめ", "はじめます", "始め", "おはよ", "出勤", "ハジメ"];

// 退勤検知ワード
const CLOCKING_OUT_WORDS = ["おつかれ", "お疲れ", "オツカレ", "退勤", "終わり", "おわり", "オワリ", "終わる", "おわる", "オワル"];

// 出勤時のBotメッセージ
const ATTENDANCE_BOT_MESSAGE = "出勤メッセージ";

// 退勤時のBotメッセージ
const CLOCKING_OUT_BOT_MESSAGE = "退勤メッセージ";

// 文章エラーメッセージ
const WORD_EROOR_MESSAGE = "文章エラーメッセージ";

// 打刻エラーメッセージ
const STAMP_ERROR_MESSAGE = "打刻エラーメッセージ";

// スプレッドシートに情報が見つからないエラーメッセージ
const INFO_MISSING_ERROR_MESSAGE = "スプレッドシートに情報が見つからないエラーメッセージ";

// 打刻の種類が正しくないエラーメッセージ
const STAMP_TYPE_ERROR_MESSAGE = "打刻の種類が正しくないエラーメッセージ";

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

    // 投稿したユーザー名
    var userName = e.parameter.user_name;

    var userId = e.parameter.user_id;

    // 発話ユーザーのemployee_idを指定
    var speechUserEmployeeId = getFreeeEmployeeId(userId);

    // nullチェック
    if (!speechUserEmployeeId) {
        message = userName + INFO_MISSING_ERROR_MESSAGE;
    } else {
        // 投稿されたメッセージ
        var postedMessage = e.parameter.text;

        var result = new Object();

        // 投稿された文章によって処理分岐
        if (isExistStringInArray(ATTENDANCE_WORDS, postedMessage)) {
            try {
                // 出勤
                result = startWorking(speechUserEmployeeId);
             
                    message = userName + ATTENDANCE_BOT_MESSAGE;
                
            } catch (e) {

                // 打刻の種類が正しくない場合
                if (e.message.includes("打刻の種類が正しくありません")) {

                    // メンション生成
                    var mention = "\n\n<@" + userName + ">";
                    message = mention + STAMP_TYPE_ERROR_MESSAGE;

                } else {

                    // 出勤時のエラー
                    message = message + STAMP_ERROR_MESSAGE + e.message;

                }
            }


        } else if (isExistStringInArray(CLOCKING_OUT_WORDS, postedMessage)) {

            try {
                // 退勤
                result = finishWorking(speechUserEmployeeId);

                message = userName + CLOCKING_OUT_BOT_MESSAGE;

            } catch (e) {
                if (e.message.includes("打刻の種類が正しくありません")) {

                    // メンション生成
                    var mention = "\n\n<@" + userName + ">";
                    message = mention + STAMP_TYPE_ERROR_MESSAGE;

                } else {

                    // 退勤時のエラー
                    message = message + STAMP_ERROR_MESSAGE + e.message;

                }
            }
        } else {

            // 言葉が当てはまらないエラー
            message = userName + WORD_EROOR_MESSAGE;

        }

    }
    sendToSlack(message, e.parameter.timestamp, STAMP_SLACK_URL);
}



// 配列の文字が文字列の行頭にあるかチェック
function isExistStringInArray(array, str) {
    var result = false;

    for (let i = 0; i < array.length; i++) {
        if (str.indexOf(array[i]) === 0) {
            result = true;
            break;
        }
    }
    return result;
}

各定数にBotからの文言を追加してください。

全体像

全体像を下記に示します。

// スプレッドシートのURL
const SPREAD_SHEET_URL = "https://docs.google.com/spreadsheets/d/19HSSp2_HjOQnSHuKoHG4muGb_be1FQKabg1ivxf1k-o/edit?gid=0#gid=0";

//シート名
const SHEET_NAME = "シート名";

// 従業員情報取得APIのURL
const GET_EMPLOYEES_APIURL = 'https://api.freee.co.jp/hr/api/v1/companies/{COMPANY_ID}/employees?company_id={COMPANY_ID}&limit=100&offset={offset}'

// 従業員情報取得APIの取得上限
const GET_EMPLOYEES_API_LIMIT = 100;

// 自社のcompany_id
const COMPANY_ID = "company_idを記載"

// Slackのトークン
const SLACK_APP_TOKEN = "トークンを記載";

// スプレッドシートオブジェクト
var spreadSheet = SpreadsheetApp.openByUrl(SPREAD_SHEET_URL);

// freee情報
const CLIENT_ID = 'CLIENT_IDを記載';
const CLIENT_SECRET = 'CLIENT_SECRETを記載';

// シートオブジェクト
var sheet = spreadSheet.getSheetByName(SHEET_NAME);

// 出勤検知ワード
const ATTENDANCE_WORDS = ["はじめ", "はじめます", "始め", "おはよ", "出勤", "ハジメ"];

// 退勤検知ワード
const CLOCKING_OUT_WORDS = ["おつかれ", "お疲れ", "オツカレ", "退勤", "終わり", "おわり", "オワリ", "終わる", "おわる", "オワル"];

// 出勤時のBotメッセージ
const ATTENDANCE_BOT_MESSAGE = "出勤メッセージ";

// 退勤時のBotメッセージ
const CLOCKING_OUT_BOT_MESSAGE = "退勤メッセージ";

// 文章エラーメッセージ
const WORD_EROOR_MESSAGE = "文章エラーメッセージ";

// 打刻エラーメッセージ
const STAMP_ERROR_MESSAGE = "打刻エラーメッセージ";

// スプレッドシートに情報が見つからないエラーメッセージ
const INFO_MISSING_ERROR_MESSAGE = "スプレッドシートに情報が見つからないエラーメッセージ";

// 打刻の種類が正しくないエラーメッセージ
const STAMP_TYPE_ERROR_MESSAGE = "打刻の種類が正しくないエラーメッセージ";

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

    // 投稿したユーザー名
    var userName = e.parameter.user_name;

    var userId = e.parameter.user_id;

    // 発話ユーザーのemployee_idを指定
    var speechUserEmployeeId = getFreeeEmployeeId(userId);

    // nullチェック
    if (!speechUserEmployeeId) {
        message = userName + INFO_MISSING_ERROR_MESSAGE;
    } else {
        // 投稿されたメッセージ
        var postedMessage = e.parameter.text;

        var result = new Object();

        // 投稿された文章によって処理分岐
        if (isExistStringInArray(ATTENDANCE_WORDS, postedMessage)) {
            try {
                // 出勤
                result = startWorking(speechUserEmployeeId);
             
                    message = userName + ATTENDANCE_BOT_MESSAGE;
                
            } catch (e) {

                // 打刻の種類が正しくない場合
                if (e.message.includes("打刻の種類が正しくありません")) {

                    // メンション生成
                    var mention = "\n\n<@" + userName + ">";
                    message = mention + STAMP_TYPE_ERROR_MESSAGE;

                } else {

                    // 出勤時のエラー
                    message = message + STAMP_ERROR_MESSAGE + e.message;

                }
            }


        } else if (isExistStringInArray(CLOCKING_OUT_WORDS, postedMessage)) {

            try {
                // 退勤
                result = finishWorking(speechUserEmployeeId);

                message = userName + CLOCKING_OUT_BOT_MESSAGE;

            } catch (e) {
                if (e.message.includes("打刻の種類が正しくありません")) {

                    // メンション生成
                    var mention = "\n\n<@" + userName + ">";
                    message = mention + STAMP_TYPE_ERROR_MESSAGE;

                } else {

                    // 退勤時のエラー
                    message = message + STAMP_ERROR_MESSAGE + e.message;

                }
            }
        } else {

            // 言葉が当てはまらないエラー
            message = userName + WORD_EROOR_MESSAGE;

        }

    }
    sendToSlack(message, e.parameter.timestamp, STAMP_SLACK_URL);
}

// 配列の文字が文字列の行頭にあるかチェック
function isExistStringInArray(array, str) {
    var result = false;

    for (let i = 0; i < array.length; i++) {
        if (str.indexOf(array[i]) === 0) {
            result = true;
            break;
        }
    }
    return result;
}

// 出勤 引数はemployee_id、戻値は結果オブジェクト
function startWorking(employeeId) {
    const accessToken = getService().getAccessToken();
    const apiURL = STAMP_APIURL.replace("{employee_id}", employeeId);

    var payload = {
        "company_id": COMPANY_ID,
        "type": "clock_in"
    };

    var options = {
        'method': 'post',
        'contentType': 'application/json',
        'headers': {
            'Authorization': 'Bearer ' + accessToken
        },
        'payload': JSON.stringify(payload)
    };

    // POST実行
    var result = UrlFetchApp.fetch(apiURL, options);

    Logger.log(result);

    return result;
}


// 退勤 引数はemployee_id、戻値は結果オブジェクト
function finishWorking(employeeId) {
    const accessToken = getService().getAccessToken();
    const apiURL = STAMP_APIURL.replace("{employee_id}", employeeId);

    var payload = {
        "company_id": COMPANY_ID,
        "type": "clock_out"
    };

    var options = {
        'method': 'post',
        'contentType': 'application/json',
        'headers': {
            'Authorization': 'Bearer ' + accessToken
        },
        'payload': JSON.stringify(payload)
    };

    // POST実行
    var result = UrlFetchApp.fetch(apiURL, options);

    Logger.log(result);

    return result;
}

// Slackへのメッセージ送信
function sendToSlack(message, timestamp, slackUrl) {

    if (timestamp != 0 || timestamp != null) {
        // Slack用のオプション
        var slackOption = {
            "text": message,
            "thread_ts": timestamp
        };
    } else {
        // Slack用のオプション
        var slackOption = {
            "text": message
        };
    }

    var payload = JSON.stringify(slackOption);

    // API叩くオプション
    var options = {
        "method": "POST",
        "contentType": "application/json",
        "payload": payload
    };

    UrlFetchApp.fetch(slackUrl, options);
}

function setSlackFreeeId() {

    var options = {
        "method": "get",
        "contentType": "application/x-www-form-urlencoded",
        "payload": {
            "token": SLACK_APP_TOKEN
        }
    };

    var url = "https://slack.com/api/users.list";
    var response = UrlFetchApp.fetch(url, options);

    var members = JSON.parse(response).members;

    let sheetEmailArray = sheet.getRange('A:A').getValues().flat().filter(String);

    // A列の空いている行
    var nextRow = sheetEmailArray.length + 1;

    var flag = false;
    // A列にあるメールアドレスを検索し、ないものだけスプレッドシートの最後の行に追加
    for (var member of members) {
        let email = member.profile.email;
        // 削除済みユーザー・ボット・自社ドメイン以外を除外
        if (!member.deleted && !member.is_bot && member.id !== "USLACKBOT" && email.includes('自社ドメイン')) {
            // スプレッドシートにないものだけスプレッドシートの最後列に追加
            if (!sheetEmailArray.includes(email)) {
                flag = true;
                let id = member.id;
                let realName = member.real_name.substring(0, member.real_name.indexOf(" /")).replace(/\s+/g, ''); // スペースと/以降を削除
                sheet.getRange(nextRow, 1).setValue(email);
                sheet.getRange(nextRow, 2).setValue(id);
                sheet.getRange(nextRow, 3).setValue(realName);
                nextRow++;
            }
        }
    }
    // 追加あったときのみfreeeのEmployee_idを設定
    if (flag) {
        // シート更新
        SpreadsheetApp.flush();
        setEmployeeId();
    }
}

// スプレッドシートにemployee_idを登録する トリガーで一週間に一度実行
function setEmployeeId() {
    const accessToken = getService().getAccessToken();


    var options = {
        'method': 'get',
        'contentType': 'application/json',
        'headers': {
            'Authorization': 'Bearer ' + accessToken
        }
    };

    // 新しい Map を作成する
    var emailUserIdMap = new Map();

    let offset = 0;

    while (true) {
        var requestUrl = GET_EMPLOYEES_APIURL.replace('{offset}', offset.toString());
        requestUrl = requestUrl.split('{COMPANY_ID}').join(COMPANY_ID);
        var response = JSON.parse(UrlFetchApp.fetch(requestUrl, options));

        // レスポンスが空のJSONである場合、ループを終了する
        if (response.length === 0) {
            break;
        }

        // JSON データを反復処理し、Map に追加する
        for (var i = 0; i < response.length; i++) {
            var user = response[i];

            emailUserIdMap.set(user.email, user.id);
        }

        // オフセットを更新
        offset += GET_EMPLOYEES_API_LIMIT;
    }

    // シートのデータを取得
    var data = sheet.getDataRange().getValues();

    // A列にあるメールアドレスを検索し、対応するuser_idをE列に記載する
    for (var i = 1; i < data.length; i++) {
        var email = data[i][0];  // A列のメールアドレス
        var userId = emailUserIdMap.get(email);  // Mapからuser_idを取得

        if (userId !== undefined) {
            sheet.getRange(i + 1, 4).setValue(userId);  // D列にuser_idを記載
        }
    }
}

// freeeのサービス取得
function getService() {
    return OAuth2.createService('freee')
        .setAuthorizationBaseUrl('https://accounts.secure.freee.co.jp/public_api/authorize')
        .setTokenUrl('https://accounts.secure.freee.co.jp/public_api/token')
        .setClientId(CLIENT_ID)
        .setClientSecret(CLIENT_SECRET)
        .setCallbackFunction('authCallback')
        .setPropertyStore(PropertiesService.getUserProperties())
}

以上で実装は完了です。

組織文化にあった開発を

アナグラムのテックチームでは、社内のシステムを開発・導入する際に組織文化を考え、本当にそのシステムを導入して効果があるのか、使ってもらえるのかを検討したうえで開発に進むようにしています。

もちろん社員のためのという想いもありますが、何よりもせっかく作ったシステムが使ってもらえなかったら悲しいですもんね。もし今回のシステムを自社に導入するとなったときも、組織文化にあっているかを考えて導入を検討してみてもらえるとうれしいです。

関連記事

TikTok広告でエンゲージメントを高める「インタラクティブアドオン」とは?種類や活用方法を紹介
TikTok広告でエンゲージメントを高める「インタラクティブアドオン」とは?種類や活用方法を紹介
続きを見る
Yahoo!ディスプレイ広告のA/Bテスト機能とは?概要から設定方法、活用のコツまで
Yahoo!ディスプレイ広告のA/Bテスト機能とは?概要から設定方法、活用のコツまで
続きを見る
Facebookダイナミック広告の仕組みと作成方法、成果を伸ばすためのターゲティング設定まで
Facebookダイナミック広告の仕組みと作成方法、成果を伸ばすためのターゲティング設定まで
続きを見る