6
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

freee APIで業務を楽しく便利にハックしよう!2021【PR】freeeAdvent Calendar 2021

Day 4

freeeの人事労務APIを使ってSlackから年休登録できるようにする

Last updated at Posted at 2021-12-03

※この記事はアドベントカレンダーfreee APIで業務を楽しく便利にハックしよう!2021の4日目の記事です。

前置き

こんにちは。
株式会社dottでエンジニャーをしているHALと言います。

弊社では、人事労務管理に人事労務freeeを利用しており、さらにSlack botからAPIを利用して、出退勤・休憩の管理をやっています。
Slack botにメンションを送ると、返事が返ってきて、さらにfreeeに出勤・退勤時刻が打刻されます。

仕事開始の表明例

Slackで仕事開始を表明するだけで打刻される、という手軽さが最高ですね。
また、botの返答も複数パターン登録してあるので、そのやりとりも結構楽しいです。

やり取りの例

やりたいけどできてなかったこと

そんな感じで便利に使っているSlack botでしたが、年休の登録についてはfreeeの画面から手動での登録が必要でした。
また、最近になって社員数が増えてきたため、お休みの共有のためにGoogle Calendarで休み登録用カレンダーを用意したのですが、そこにも書き込む必要があります。
また、シャチョーへの年休申請→承認というフローも(否認されることはないので形式的ですが)通す必要があります。

つまり、以下の3ステップを手動で実施していました。

  1. 年休申請用チャンネルでシャチョー宛に「○月×日にお休みさせてください」と連絡する(シャチョーが「承認」と返信する)
  2. freeeの画面を開いて該当日に年休を登録する
  3. Google Calendarを開いて「みんなのお休みカレンダー」に休みを追加する

地味だけど、そこそこの回数繰り返す作業なので、これも自動化したいよねーということで、今回対応を行いました。

作ったものの仕様

作ったものの仕様は、まぁ先ほどの3ステップをSlack上で実現しただけですが、以下の通りです。

  1. Slackのショートカットから実行し、年休登録用のModalを表示する
  2. 登録するとシャチョー宛にメンションが飛ぶ
  3. シャチョーが「承認」ボタンを押すとfeeeeとGoogle Calendarに休みが登録される
ショートカットから表示されるModal ***ショートカットから表示されるModal*** 申請メッセージサンプル ***シャチョーにメンションが飛ぶ(サンプルのため自分宛になってます)*** 申請メッセージ(承認後) ***承認されると表示が変わり、botからメンションが来る*** freeeに休みが登録される ***freeeに有給が登録される*** Google Calendarにイベントが登録される ***Google Calendarにも休みが登録される***

Slack botの元の作りについて

元々の勤怠管理Botがありますので、今回はそこに機能を追加していきます。
勤怠botは以下のような構成で作られていました。

いずれもGCPで

  • App Engine
  • Cloud Functions
  • Firestore

言語(FW):Python3.8(Flask 1系)

を利用しています。

流れとしては以下のような感じです。

スクリーンショット 2021-11-20 12.14.49.png

  1. SlackのメンションイベントからGAEをキックする
  2. GAEがSlackのRequestを解析して必要な情報をFirestoreに登録する
  3. FirestoreのonCreateイベントでCloud Functionsを実行
  4. Cloud Functionsの中でfreee人事労務APIを実行して出退勤時刻を登録
  5. 完了したらSlack APIを呼び出してチャンネルにメッセージを投稿

2で一度GAEからFirestoreへの登録を挟んでいるのは、Slackのいわゆる「3秒ルール」に対応するためです。
Slackからのイベントによる呼び出しは、3秒以内にレスポンスを返さないといけないので、時間のかかる処理はFirestoreのCreateイベントでCloud Functionを呼び出すこととし、GAEは3秒以内にレスポンスを返せるようにしています。
ちなみにここは、今ならCloud Functions+Cloud Tasksなどでも実現可能です。

今回はこの流れの中でSlackショートカットコマンドとブロックアクションによる実行と、freee人事労務APIで年休の登録、さらにGoogle Calendarにイベントの登録をする部分を追加していきます。

今回説明しないこと

今回は以下の内容については説明しません。
必要な方は他の方の記事などで素晴らしい記事がたくさんありますので、そちらを参照してください。

 ◆ Slack botの作り方(初期設定) 
 ◆ GAE/Cloud Functions/Firestore等のGCPサービスの初期設定
 ◆ freeeの開発環境の使い方
 ◆ Python/Flaskについて

Slack botの設定変更

最初にSlack botの各種設定変更を実施します。

権限の割り当て(OAuth & Permissions)

まずはSlack botに必要な権限を割り当てていきます。
今回利用するbotでは、以下の機能を利用していきます。

  • Slackショートカット
  • 公開チャンネルへのメッセージの送信
  • メッセージ送信時のアイコン・表示名のカスタマイズ
  • ユーザーのプロフィール参照(ユーザーのアイコン・表示名を取得するため)

そのため、https://api.slack.com/apps/{自身のAPPID}/oauthを開き、Scopes > Bot Token Scopesのセクションで、以下の4つのスコープを追加します。

  • chat:write <- チャンネルへのメッセージ投稿
  • chat:write.customize <- メッセージ投稿時のアイコンと名前のカスタマイズ
  • commands <- ショートカット、Blockアクションの利用
  • user:read <- ユーザーのアイコン、表示名の取得

なお、既存の仕組みで「app_mentions:read」を利用していたため以下のキャプチャでは5つのScopeが追加されています。

追加したBot Token Scopes

ショートカットの設定(Interactivity & Shortcuts)

続いて、登録フォームのModalを起動するためのSlackショートカットを設定していきます。
以下のページを開きます。

https://api.slack.com/apps/{自身のAPPID}/interactive-messages

まず「Request URL」に、ショートカットが実行されたら呼び出してほしいURLを設定します。
今回は「/slack/shortcuts」というエンドポイントをGAE上に用意してそれを設定しました。

「Shortcuts」のセクションで「Create New Shortcut」をクリックし、ショートカットを追加します。

ショートカットタイプの選択

今回はGrobal Shortcutとして作成します。
Grobal Shortcutはどのチャンネルの入力エリアのボタンからでも実行可能です。
On messagesの場合はメッセージの3点リーダ(・・・)から実行する形になります。

Slackショートカット設定例

こんな感じで適宜入力して「Create」をクリックします。
すると、以下のように登録されます。

Slackショートカット設定後

ここで登録した「Callback ID」が、後で「どのショートカットから実行されたか」を判定するために必要なので良い感じのIDを設定しておきましょう。

以上でSlack側で必要な設定は完了です。
Slackのアプリ管理ページの上の方に再インストールを促すメッセージが出ていると思いますので、「reinstall your app」のリンクをクリックして追加した権限を有効化しましょう。

再インストールを促すメッセージ

GAEでSlackからのイベントを受け付ける

続いてSlackのイベントを受け付けるGAE側の処理を実装していきます。
なお、今回はPython(Flask)を使用していますが、初期構築方法は説明しません。
Slack botの構築に関してはお好きな環境・言語でお好きなように準備をしてください。
コードも一部は載せますが、他の言語でも応用可能なように仕組みの説明を重点的にしようと思います。

エンドポイントの設定

今回はFWとしてFlaskを使っています。
まず、先ほどSlackショートカットの作成時に設定しておいた、URL用のエンドポイントを設定します。

slack.py
import urllib.parse

from flask import request
from flask_restx import Namespace, Resource
from service import slack_service

ns_slack = Namespace(
    'slack',
    description='',
    path='/slack'
)


@ns_slack.route('/shortcuts')
class SlackShortcutsResource(Resource):

    def post(self):
        body = urllib.parse.unquote(
            request.get_data().decode('UTF-8').replace('+', '%20'))
        slack_service.handle_shortcuts(body.split('=')[1])
        res = {}

        return res, 200

上記コードのポイントとしては、受け取ったリクエストのパース方法にあります。
ショートカットイベントについては、リクエストボディはURLエンコードされたJson文字列をpayload=の後ろに設定し、さらにバイナリーとして送られてくるので、その逆の処理をしてあげる必要があります。
上記コードでは以下の順で処理しています。

  1. request.get_data()でbody部取得
  2. decode('UTF-8')でbyte→stringに変換
  3. 半角スペースが+にエスケープされているが、そのままだとデコードできないのでreplace('+', '%20')%20に置換
  4. urllib.parse.unquote(〜〜)でURLエンコードされた文字列をデコード
  5. body.split('=')[1]payload=部分を除去し、JSON文字列部分だけをslack_service.handle_shortcutsに渡している

とりあえずここではリクエストを受け付けてJSONを取り出してサービスに渡しているだけですね。
具体的な処理はslack_serviceの方で実施します。

ショートカット実行からモーダルの表示

続いて、モーダルを表示する処理を実装していきます。
先ほどのslack.pyからhandle_shortchtsが呼び出された後の処理です。

slack_service.py
def handle_shortcuts(req_data: str) -> dict:
    req_body = json.loads(req_data)
    req_shortcut = req_body.get('callback_id')
    trigger_id = req_body.get('trigger_id')

    if req_shortcut == 'entry_holiday_shortcut':
        return post_modal(trigger_id)

def post_modal(trigger_id: str) -> requests.Response:
    print(f'post_modal: {trigger_id}')
    headers = {
        'content-type': 'applecation/json',
        'Authorization': f'Bearer {SLACK_AUTH_TOKEN}'
    }
    data = {
        'trigger_id': trigger_id,
        'view': json.dumps({
            "type": "modal",
            "callback_id": "entry_holiday_modal",
            "title": {
                "type": "plain_text",
                "text": "お休み登録",
                "emoji": True
            },
            "submit": {
                "type": "plain_text",
                "text": "登録",
                "emoji": True
            },
            "close": {
                "type": "plain_text",
                "text": "キャンセル",
                "emoji": True
            },
            "blocks": [
                {
                    "type": "input",
                    "block_id": "holiday",
                    "element": {
                            "type": "datepicker",
                                    "action_id": "input",
                                    "placeholder": {
                                        "type": "plain_text",
                                        "text": "日付を選択してください",
                                        "emoji": True
                                    }
                    },
                    "label": {
                        "type": "plain_text",
                        "text": "お休みする日",
                        "emoji": True
                    },
                    "optional": False
                },
                {
                    "type": "section",
                    "text": {
                        "type": "mrkdwn",
                        "text": "休みの種別"
                    },
                    "block_id": "holiday_type",
                    "accessory": {
                        "type": "radio_buttons",
                        "options": [
                            {
                                "text": {
                                    "type": "plain_text",
                                    "text": "📆 一日",
                                    "emoji": True
                                },
                                "value": "day"
                            },
                            {
                                "text": {
                                    "type": "plain_text",
                                    "text": "🌅 AM半休",
                                    "emoji": True
                                },
                                "value": "am"
                            },
                            {
                                "text": {
                                    "type": "plain_text",
                                    "text": "🌆 PM半休",
                                    "emoji": True
                                },
                                "value": "pm"
                            }
                        ],
                        "action_id": "ignore-action"
                    }
                },
                {
                    "type": "input",
                    "block_id": "send_to",
                    "element": {
                        "type": "multi_users_select",
                        "placeholder": {
                            "type": "plain_text",
                            "text": "連絡先ユーザーを選択してください",
                            "emoji": True
                        },
                        "action_id": "multi_users_select-action",
                        "initial_users": ["U0HKMSSS0"]
                    },
                    "label": {
                        "type": "plain_text",
                        "text": "連絡先ユーザー",
                        "emoji": True
                    }
                },
                {
                    "type": "input",
                    "block_id": "input-description",
                    "element": {
                        "type": "plain_text_input",
                        "action_id": "ignore-action",
                        "multiline": True,
                        "placeholder": {
                            "type": "plain_text",
                            "text": "何か伝えておきたいことがあれば",
                            "emoji": True
                        }
                    },
                    "label": {
                        "type": "plain_text",
                        "text": "コメント",
                        "emoji": True
                    },
                    "optional": True
                }
            ]
        }),
    }
    return requests.post(SLACK_VIEW_URL, headers=headers, params=data)

ここでSlackショートカットを追加した際の「Callback ID」が出てきました(req_shortcut = req_body.get('callback_id') )。
このIDを利用して処理を分岐したりします。
今のところは、ショートカットが一つだけなのでifで分ける必要もなさそうな感じですが、後で増えます。

post_modalの中で長々とJSONを書いていますが、これがいわゆるSlackのBlok Kitですね。
Block Kit Builderで表示の確認などが可能です。
リクエストの中身も見れるので参考になります。

で、これをSlackのview.openエンドポイントにリクエストすることで、Modalが表示できます。
再掲になりますが、こんな感じになります。

ショートカットから表示されるModal ***表示されるModal***

リクエストの呼び方はこちらも参考にしてください。

モーダルの登録ボタンイベントの処理

続いてはモーダルで「登録」ボタンが押された時の処理です。
この時のイベントも、Slackショートカットで設定したRequest URLにイベントが通知されます。
そのため、先ほどのhandle_shortcutsに追記していきます。

slack_service.py(handle_shortcutsのみ)
def handle_shortcuts(req_data: str) -> dict:
    req_body = json.loads(req_data)
    req_shortcut = req_body.get('callback_id')
    type = req_body.get('type')
    trigger_id = req_body.get('trigger_id')

    if type == 'view_submission':
        state = req_body.get('view').get('state').get('values')
        user = req_body['user']['id']
        entryData = {
            'type': type,
            'user': user,
            'send_to': json.dumps(state['send_to']['multi_users_select-action']['selected_users']),
            'holiday': state['holiday']['input']['selected_date'],
            'holiday_type': state['holiday_type']['ignore-action']['selected_option']['value'],
            'note': state['input-description']['ignore-action']['value'],
        }
        return db_service.set_slack_events(trigger_id, entryData)
    elif req_shortcut == 'entry_holiday_shortcut':
        return post_modal(trigger_id)

主に追加したのはif type == 'view_submission':の部分ですね。
モーダルからのイベントはこのview_submissionというtypeが渡されてきます。
今回はモーダルは一つしか使わないので、このタイプだけで判定しています。
仮にモーダルを複数利用する場合は、さらにblock_idやaction_idなどを駆使して、どのモーダルから来たのか判定してあげる必要があります。

ちなみにここではdb_service.set_slack_eventsの中でfirestoreにtrigger_idをキーとしてentryDataをそのまま登録しているだけです。

ここで登録されたことで、firestoreのonCreateイベントからCloud Functionsが実行されます。
実行されるのは以下のfunctionです。

firestore.py
def register_attendance(data, context):
    value = data.get('value')
    if value is None:
        return

    data_fields = value.get('fields')

    user_id = (data_fields.get('user') or {}).get('stringValue') or ''
    channel_id = (data_fields.get('channel') or {}).get('stringValue') or ''
    text = (data_fields.get('text') or {}).get('stringValue') or ''
    thread_timestamp = (data_fields.get('ts') or {}).get('stringValue')
    today = datetime_util.get_datetime_now()
    type = (data_fields.get('type') or {}).get('stringValue') or ''

    # main
    try:
        if type == 'view_submission':
            user = (data_fields.get('user') or {}).get('stringValue') or ''
            holiday = (data_fields.get('holiday')
                       or {}).get('stringValue') or ''
            holiday_type = (data_fields.get('holiday_type') or {}
                            ).get('stringValue') or ''
            send_to = json.loads((data_fields.get('send_to')
                                  or {}).get('stringValue')) or ['']
            note = (data_fields.get('note') or {}).get('stringValue') or ''
            res = post_request_approve(
                user, send_to, holiday, holiday_type, note)
            return

    except Exception as e:
        print(e)
        _post_error_message(e, channel_id, user_id, thread_timestamp)
        return

    slack_service.post_action_not_found(channel_id, user_id, thread_timestamp)

# 承認依頼メッセージの投稿
def post_request_approve(user: str, send_to: list, holiday: str, holiday_type: str, note: str) -> requests.Response:

    user_info = get_user_info(user).json()
    user_name = user_info['user']['profile'].get(
        'display_name') or user_info['user']['profile'].get('real_name') or 'John Doe'
    user_image = user_info['user']['profile'].get('image_512')

    mention = []

    for user_id in send_to:
        mention.append(f'<@{user_id}>')

    type_msg = '一日' if holiday_type == HolidayTypes.DAY.value else \
        '午前' if holiday_type == HolidayTypes.AM.value else '午後'

    day = datetime_util.get_datetime_from_str(holiday)
    locale.setlocale(locale.LC_TIME, 'ja_JP.UTF-8')
    holiday_fmt = (f'{day: %m/%d %A}')

    headers = {
        'content-type': 'applecation/json',
        'Authorization': f'Bearer {SLACK_AUTH_TOKEN}'
    }
    data = {
        'channel': SLACK_REQUEST_CHANNEL,
        'username': user_name,
        'icon_url': user_image,
        "blocks": json.dumps([
            {
                "block_id": "message1",
                "type": "section",
                "text": {
                        "type": "mrkdwn",
                        "text": f"{' '.join(mention)}\n以下の通り年休を申請します。"
                }
            },
            {
                "type": "section",
                "block_id": "section1",
                "fields": [
                        {
                            "type": "mrkdwn",
                            "text": f"*いつ:*  {holiday_fmt}"
                        },
                    {
                            "type": "mrkdwn",
                            "text": f"*種別:*  {type_msg}"
                        }
                ]
            },
            {
                "type": "section",
                "block_id": "section2",
                "text": {
                        "type": "mrkdwn",
                        "text": f"*コメント:*\n{note}"
                }
            },
            {
                "type": "actions",
                "block_id": "approve",
                "elements": [
                        {
                            "type": "button",
                            "text": {
                                    "type": "plain_text",
                                    "emoji": True,
                                    "text": "承認"
                            },
                            "action_id": "approve",
                            "style": "primary",
                            "value": f"{user},{holiday},{holiday_type}"
                        }
                ]
            }
        ])
    }
    return requests.post(SLACK_POST_URL, headers=headers, params=data)

実際は他の処理(出退勤の処理など)もあるのでもうちょっと長いですが、省略してます。
やっていることとしては、firestoreに登録されたデータを読み出して、Block Kitの形に合わせて整形し、メッセージをSlackに投稿しているだけです。

この中でポイントになるのは、最後の方のBlocksの中の"value": f"{user},{holiday},{holiday_type}"でしょうか。
Slackではメッセージの中にhiddenで何らかの値を持たせることができません。
ただ、承認後に処理をつなげるために、承認時に必要なデータを持たせておきたい。
そこで、actionsの中のvalueにカンマ区切りで埋め込むことにしました。
(申請ユーザーのSlack UID、休む日付、一日か半日かの区別)

これを送信すると、再掲となりますが、以下のようなメッセージが投稿されます。

申請メッセージサンプル

承認ボタンイベントの処理

この時のイベントも、Slackショートカットで設定したRequest URLにイベントが通知されます。
そのため、先ほどのhandle_shortcutsにさらに追記していきます。

slack_service.py(handle_shortcutのみ)
def handle_shortcuts(req_data: str) -> dict:
    req_body = json.loads(req_data)
    req_shortcut = req_body.get('callback_id')
    type = req_body.get('type')
    trigger_id = req_body.get('trigger_id')

    if type == 'view_submission':
        state = req_body.get('view').get('state').get('values')
        user = req_body['user']['id']
        entryData = {
            'type': type,
            'user': user,
            'send_to': json.dumps(state['send_to']['multi_users_select-action']['selected_users']),
            'holiday': state['holiday']['input']['selected_date'],
            'holiday_type': state['holiday_type']['ignore-action']['selected_option']['value'],
            'note': state['input-description']['ignore-action']['value'],
        }
        return db_service.set_slack_events(trigger_id + '_' + user, entryData)
    elif type == 'block_actions':
        approve_user = req_body.get('user').get('id')
        ts = req_body.get('container').get('message_ts')
        channel = req_body.get('container').get('channel_id')
        blocks = json.dumps(req_body.get('message').get('blocks'))
        action_result = req_body.get('actions')[0]
        values = action_result.get('value').split(',')
        entryData = {
            'type': type,
            'approve_user': approve_user,
            'user': values[0],
            'holiday': values[1],
            'holiday_type': values[2],
            'blocks': blocks,
            'ts': ts,
            'channel': channel,
        }
        return db_service.set_slack_events(ts, entryData)
    elif req_shortcut == 'entry_holiday_shortcut':
        return post_modal(trigger_id)

今度はtypeがblock_actionsというので渡ってきます。
今回もblock_actionsは一つしかありませんのでtypeだけを使って分岐してます。
そして同様に、firestoreに必要な情報を登録しているだけです。

1点ポイントとしては、後ほどメッセージの書き換え(ボタンだったものを「承認されました!」のメッセージに書き換える)をやりたいので、イベントで渡されてきたblocksオブジェクトをJSONから文字列に変換して保存しています。
後でこいつを使って、メッセージ内容の書き換えを行います。

登録すると、モーダルのイベント処理時と同様に、以下のfunctionsが実行されます。

firestore.py
def register_attendance(data, context):
    value = data.get('value')
    if value is None:
        return

    data_fields = value.get('fields')

    user_id = (data_fields.get('user') or {}).get('stringValue') or ''
    channel_id = (data_fields.get('channel') or {}).get('stringValue') or ''
    text = (data_fields.get('text') or {}).get('stringValue') or ''
    thread_timestamp = (data_fields.get('ts') or {}).get('stringValue')
    today = datetime_util.get_datetime_now()
    type = (data_fields.get('type') or {}).get('stringValue') or ''

    # main
    try:
        if type == 'block_actions':

            holiday = (data_fields.get('holiday') or {}).get('stringValue') or ''
            holiday_type = (data_fields.get('holiday_type') or {}).get('stringValue') or ''
            blocks = (data_fields.get('blocks') or {}).get('stringValue') or {}
            approve_user = (data_fields.get('approve_user') or {}).get('stringValue') or {}

            _post_entry_holiday(
                channel_id, user_id, holiday, holiday_type, blocks, thread_timestamp, approve_user)

            return
        elif type == 'view_submission':
            user = (data_fields.get('user') or {}).get('stringValue') or ''
            holiday = (data_fields.get('holiday')
                       or {}).get('stringValue') or ''
            holiday_type = (data_fields.get('holiday_type') or {}
                            ).get('stringValue') or ''
            send_to = json.loads((data_fields.get('send_to')
                                  or {}).get('stringValue')) or ['']
            note = (data_fields.get('note') or {}).get('stringValue') or ''
            res = post_request_approve(
                user, send_to, holiday, holiday_type, note)
            return

    except Exception as e:
        print(e)
        _post_error_message(e, channel_id, user_id, thread_timestamp)
        return

    slack_service.post_action_not_found(channel_id, user_id, thread_timestamp)

def _post_entry_holiday(channel_id: str, user_id: str, holiday_date: str, holiday_type: str = 'day',
                        blocks: str = '', thread_timestamp: str = '', approve_user: str = ''):
    if not slack_service.chek_approvable_user(approve_user):
        return slack_service.post_entry_holiday_message(
            channel_id, [], False, approve_user, thread_timestamp)

    day = datetime_util.get_datetime_from_str(holiday_date)

    # 年休の残日数を取得(割愛)
    users_paid_holidays_left = _generate_users_paid_holidays_left([user_id])
    paid_holidays_left = users_paid_holidays_left[0].get('paid_holidays_left')

    if paid_holidays_left <= 0:
        # ユーザー登録なし or 年休残りなし
        slack_service.post_entry_holiday_message(
            channel_id, users_paid_holidays_left, True, approve_user, thread_timestamp)
        return
    else:
        # 年休の登録
        access_token = freee_token.get_access_token()
        user_data = _get_and_register_user_data(user_id, day)
        emp_id = user_data.get('emp_id')

        # 勤怠データのチェックと作成(登録ずみ勤怠データなどをチェック)
        checker_and_generator = check_updatable_and_generator_work_record_by_event_type
        work_record = checker_and_generator(
            EventTypes.ENTRY_HOLIDAY.value, user_id, access_token, emp_id, day, "note", holiday_type)

        # 勤怠情報の更新
        register_date = datetime.fromisoformat(
            work_record.get('date')).strftime('%Y%m%d')
        freee_service.put_work_records(
            access_token, emp_id, register_date, work_record)

    # カレンダーにイベントを作成
    calendar_service.create_event(user_id, holiday_date, holiday_type)

    slack_service.post_entry_holiday_message(
        channel_id, users_paid_holidays_left, True, approve_user, thread_timestamp,  blocks, )

def check_updatable_and_generator_work_record_by_event_type(
    event_type: str, user_id: str, access_token: str, emp_id: str, today: datetime, 
    note: str, holiday_type: HolidayTypes = HolidayTypes.DAY.value) -> dict:

    #
    # 各種event_typeによる処理...割愛
    #

    # お休み登録の場合
    if event_type == EventTypes.ENTRY_HOLIDAY.value:
        # 各種チェック処理(割愛)
        _check_updatable_holiday_work_record(
            access_token, emp_id, today, user_id)
        return _create_holiday_work_record(access_token, emp_id, today, holiday_type)

    raise Exception('invalid event type')

def _create_holiday_work_record(access_token: str, emp_id: str, today: datetime, holiday_type: HolidayTypes) -> dict:
    # 一度休みの予定日の今のデータを取得し、それを加工する
    work_record = freee_service.get_work_records(
        access_token, emp_id, today.strftime('%Y%m%d'))

    if holiday_type != HolidayTypes.DAY.value:

        # 半休の場合は勤務開始・終了・休憩を設定する
        clock_in_at = datetime_util.change_time(today, h=9, m=0, s=0, ms=0)
        clock_out_at = datetime_util.change_time(today, h=18, m=0, s=0, ms=0)
        break_in_at = datetime_util.change_time(today, h=12, m=0, s=0, ms=0)
        break_out_at = datetime_util.change_time(today, h=13, m=0, s=0, ms=0)

        # 出退勤時間の設定
        work_record['clock_in_at'] = datetime_util.format_freee_date(
            clock_in_at)
        work_record['clock_out_at'] = datetime_util.format_freee_date(
            clock_out_at)

        # 半休の場合は休憩の開始・終了を設定
        if len(work_record.get('break_records')) == 0:
            work_record['break_records'].append({
                'clock_in_at': datetime_util.format_freee_date(break_in_at),
                'clock_out_at': datetime_util.format_freee_date(break_out_at)
            })
        else:
            work_record['break_records'][0]['clock_in_at'] = datetime_util.format_freee_date(
                break_in_at)
            work_record['break_records'][0]['clock_out_at'] = datetime_util.format_freee_date(
                break_out_at)

    # 1日の場合は1、半休の場合は0.5を設定
    work_record['paid_holiday'] = 1 if holiday_type == HolidayTypes.DAY.value else 0.5

    # 1日の場合は0、半休の場合は240分(4時間)を設定
    work_record['normal_work_mins_by_paid_holiday'] = 0 if holiday_type == HolidayTypes.DAY.value else 240

    return work_record

ここでもtype='block_actionsを用いて分岐させます。
また、_post_entry_holidayではチェックと各登録処理を呼び出しているだけですね。

freeeへの登録

check_updatable_and_generator_work_record_by_event_typeでは登録しても良いかチェックしています。
ソースコードは割愛しますが、freeeに登録されているユーザーか、すでに勤怠が登録されていないか、などをチェックします。
ここは業務的に問題ないかのチェックです。

そして、_create_holiday_work_recordで、freeeのAPIに登録するためのデータを作成します。
APIリファレンスに沿って作成していきます。
一日休と半休で作成すべきデータが違うので注意が必要です。

主な違いとして、1日休の場合は勤務時間や休憩時間がなく、所定労働時間も0で設定しますが、半休の場合は勤務時間・休憩時間の設定が必要で、所定労働時間は半休を取得した場合の勤務時間(今回は4時間)の設定が必要です。

そして、作成したwork_recordをfreee_service.put_work_recordsに渡してAPIを呼び出します。

freee_service.py(抜粋)
def put_work_records(access_token: str, emp_id: str, strf_today: str, work_record: dict):
    url = FREEE_WORK_RECORDS_URL.format(emp_id, strf_today)
    headers = {
        'Content-type': 'application/json',
        'Authorization': 'Bearer ' + access_token
    }
    data = work_record

    res = requests.put(url, headers=headers, data=json.dumps(data))
    res.raise_for_status()
    return

やってることは、ただAPIの呼び出しの規約に沿ってデータを渡しているだけですね。

Google Calendarへの登録

続いてGoogle Calendarへの登録処理について説明します。
処理のソースは以下の通りです。

calendar_service.py
import google.auth
import googleapiclient.discovery
from util.config import config
from util.types_util import HolidayTypes

from service.slack_service import get_user_info

# Preparation for Google API
SCOPES = config.calendar_config.scopes
CALENDAR_ID = config.calendar_config.calendar_id
CREDENTIAL_FILE_PASS = config.calendar_config.credential_file_pass


def create_event(user_id: str, holiday_date: str, holiday_type: HolidayTypes):

    gapi_creds = google.auth.load_credentials_from_file(
        CREDENTIAL_FILE_PASS, SCOPES)[0]
    service = googleapiclient.discovery.build(
        'calendar', 'v3', credentials=gapi_creds)

    start_time = '09:00' if holiday_type != HolidayTypes.PM.value else '14:00'
    end_time = '18:00' if holiday_type != HolidayTypes.AM.value else '13:00'

    print(f'start_time: {start_time}, end_time:{end_time}')

    user_info = get_user_info(user_id).json()
    user_name = user_info.get('user').get('profile').get(
        'display_name') or user_info.get('user').get('profile').get('real_name') or 'John Doe'

    event = {
        'summary': f'{user_name}:お休み',
        'allDayEvent': holiday_type == HolidayTypes.DAY.value,
        'start': {
            'dateTime': f'{holiday_date}T{start_time}:00+09:00',
            'timeZone': 'Asia/Tokyo',
        } if holiday_type != HolidayTypes.DAY.value else {
            'date': holiday_date
        },
        'end': {
            'dateTime': f'{holiday_date}T{end_time}:00+09:00',
            'timeZone': 'Asia/Tokyo',
        } if holiday_type != HolidayTypes.DAY.value else {
            'date': holiday_date
        },
    }

    event = service.events().insert(calendarId=CALENDAR_ID, body=event).execute()

まず必要なのがサービスアカウントのクレデンシャルです。
GCPのIAMからサービスアカウントを作成し、鍵を作ってcredentials.jsonをダウンロードしてください。

credentials.json作成の参考サイト

で、そのファイルへのファイルパスをCREDENTIAL_FILE_PASSに設定します。
なお、よりセキュアに管理したい場合は、Secret Managerなどを利用しましょう。

あとはGoogle Calendar APIのリファレンスに従って、イベントの内容を適宜加工して登録してあげます。
なお、途中でget_user_info(user_id).json()とやっているのは、Slackの表示名を取得するための処理です。
イベントのタイトルを「Slackの表示名+”:お休み”」と設定するために取得しています。

処理完了メッセージの送信

最後に、処理完了のメッセージを送信します。

slack_service.py(抜粋)
def post_entry_holiday_message(channel_id: str, users_paid_holidays_left: [dict],  is_approve: bool,
                               approve_user: str = '', thread_timestamp='', blocks: str = '',  index=-1):
    icon_url, messages = _generate_icon_url_and_message(
        message_util.ENTRY_HOLIDAY_MSG, index)
    msg_format = messages.get('msg')
    not_fount_msg_format = messages.get('not_found_msg')
    no_holiday_left_format = messages.get('no_holiday_left')
    not_permitted_format = messages.get('not_permitted')

    if(not is_approve):
        return post_message(channel_id, not_permitted_format.format(
            approve_user), icon_url, thread_timestamp)

    for data in users_paid_holidays_left:
        slack_uid = data.get('slack_uid')
        paid_holidays_left = data.get('paid_holidays_left')

        # ユーザーが存在しない場合(設定もれ)
        if paid_holidays_left == -1:
            # スレッドに返信
            post_message(channel_id, not_fount_msg_format.format(
                slack_uid), icon_url, thread_timestamp)
        # 休みがもうない場合
        elif paid_holidays_left == 0:
            # スレッドに返信
            post_message(channel_id, no_holiday_left_format.format(
                slack_uid), icon_url, thread_timestamp)
        else:

            user_info = get_user_info(approve_user).json()
            user_name = user_info['user']['profile'].get(
                'display_name') or user_info['user']['profile'].get('real_name') or 'John Doe'

            block_dict = json.loads(blocks)
            block_dict[3] = {
                "type": "section",
                "text": {
                    "type": "plain_text",
                    "text": f"✅ 申請が{user_name}さんに承認されました!",
                    "emoji": True
                }
            }

            headers = {
                'content-type': 'applecation/json',
                'Authorization': f'Bearer {SLACK_AUTH_TOKEN}'
            }

            data = {
                'ts': thread_timestamp,
                'channel': channel_id,
                'blocks': json.dumps(block_dict)
            }

            res = requests.post(SLACK_CHAT_UPDATE_URL,
                                headers=headers, params=data)
            msg = msg_format.format(slack_uid, approve_user)

            # スレッドに返信
            post_message(channel_id, msg, icon_url, thread_timestamp)

色々やっているんですが、元々のbotの仕様でキャラクターが二人いて、そのアイコンの切り替えや文章の切り替え(口調が違うため)をする処理が結構並んでます。

ポイントになるのは以下の部分です。

抜粋
            block_dict = json.loads(blocks)
            block_dict[3] = {
                "type": "section",
                "text": {
                    "type": "plain_text",
                    "text": f"✅ 申請が{user_name}さんに承認されました!",
                    "emoji": True
                }
            }

            res = requests.post(SLACK_CHAT_UPDATE_URL,
                                headers=headers, params=data)

これが、投稿済みのメッセージの書き換えをしている部分です。
blocksにはアクションを起こしたメッセージのbloks定義がそのまま入っているので、その中のボタンに該当する部分(今回は配列4つ目の要素)をテキストに置き換えることで、ボタンを消して再実行されないように書き換えを行います。
これをSlackのchat.updateを呼び出してやることで、Slack上のメッセージが以下のように書き変わります(再掲)。

申請メッセージ(承認後)

これをしておかないと何度でも押せてしまうので変な感じですね。

補足:Slackとfreeeのユーザーの連携について

コードを割愛した関係で説明できていませんが、SlackのユーザーIDとfreeeの従業員IDはfirestore上にマスタデータとして紐付けを登録しています。
そのため、SlackのユーザーIDがわかれば対象の従業員IDが取得できる、という仕組みになっています。
この辺は、スプレッドシートで管理するとか、好きな方法でやっていただければと思います。

Google Calendarの利用設定

最後になりましたが、Google Calendarへの書き込みのための設定をしていきます。
Google Calendarの利用は通常だとOAuthの設定をしてやるんですが、とんでもなくめんどくさいです。
そこで、GCP上で動かすのを利用して、サービスアカウントだけでアクセス許可させてしまいます。

まず、Google Calendar APIを有効化します。
GCPコンソールメニューの「APIとサービス」で「Google Calendar API」を探し、有効化します。
(この辺から行けるかな・・・?)

続いて、Google Calendarのイベントを追加させたいカレンダーを開き、「設定と共有」を開きます。

設定と共有

「特定のユーザーとの共有」のセクションで、先ほどCredentialを作成したサービスアカウントを追加してやります。

特定のユーザーとの共有

「予定の変更」の権限で追加してやれば完了です。
これだけで、Cloud FunctionsからGoogle Calendarに書き込みが可能です。
簡単ですね!

最後に

さて、コードやキャプチャなども含めたのでかなり長くなってしまいましたが、これでSlackからfreeeとGoogle Calendarにお休みの登録ができました!
自動化することでヒューマンエラーも防げますし、何よりちょっと楽しい感じで仕事ができるようになったのではないかと思います。

freeeのAPIとGoogle CalendarのAPIを使ってみて、やはりこういったAPIが公開されていて、好きに使えると色々とやれることの幅が広がっていいですね。
今後もさらに改善できることがないか検討して、色々なAPIの連携などを試してみたいと思います。

6
3
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
6
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?