以下の内容はhttps://tech.layerx.co.jp/entry/2025/01/21/115503より取得しました。


バクラク勤怠におけるSlack連携のアーキテクチャ

バクラク事業部エンジニアの id:itkq です。ラブライブ!全国決勝大会プレーオフが迫り、緊張感が続く日々を送っています。

最近私はバクラク勤怠のSlack連携関連機能を開発しています。この記事では、Slack連携のアーキテクチャについて紹介します。

バクラク勤怠とは

バクラク事業部では、これまでバクラク請求書受取をはじめとするBSM (Business Spend Management / 法人支出管理) 領域のサービスを複数リリースしてきました。お客様のお話を伺うなかで、勤怠サービスへの期待の声を多く頂いたことをきっかけに、開発することが決まりました。

comemo.nikkei.com

これまでのバクラクシリーズ同様、従業員・管理者両方の体験についてこだわり抜いた勤怠サービスを目指しています。

Slack連携

バクラク勤怠の特徴として、Slack連携が挙げられます。Slack経由で打刻できるだけでなく、残業時間超過などの通知に加え、打刻漏れや勤怠申請の承認依頼通知に対してSlack上で直接申請や承認が行えたりもします。次の記事でこれらについて触れられています。

bakuraku.jp

バクラク勤怠のSlack連携は、Slack AppのApp Homeを基本としています。App Homeを利用することで、打刻などの操作が直感的に行えるだけでなく、比較的高い自由度で、同時に勤怠エラーなどの情報を見せられることが魅力でした。これにより、利用者はSlackを離れないまま、安心して操作を完結できる場面が増えると考えています。

Slack Appのシーケンス

App Homeを含むSlack Appの基本的なシーケンスは以下の図で表されます。

Slack Appの基本シーケンス

エンドユーザーのSlack上で起きたイベントはまずSlack APIを経由し、Slack Appで設定したエンドポイントにリクエストが飛んできます。このリクエストを起点として必要な処理を行い、最終的にviews.publishviews.update APIを使ってビューやモーダルを更新します。

Webアプリのアーキテクチャ

バクラク勤怠のWebアプリの抽象アーキテクチャは以下のようになっています。graphql-gatewayというGraphQLを喋るゲートウェイを採用しており、それ以降のレイヤでのサービス間通信にはConnect RPCを採用しています。この構成は、現在ではバクラク事業部では標準化されつつあり、実際新規立ち上げにおいてかなり楽をすることができました。

バクラク勤怠Webアプリの抽象アーキテクチャ

Slack連携のアーキテクチャ

全体の流れは次の図のようになっており、特徴を三行でまとめると以下になります。

  • SlackからのリクエストはGoの小さなサービスで受け、対応するバクラクユーザーを特定
  • 認証トークンを取得しつつ、内部的にBolt for JavaScriptを利用したConnect RPCを叩く
  • GraphQL APIを叩いて必要なデータを取得し、jsx-slackでviewを組み立てる

バクラク勤怠Slack連携のシーケンス図

メールアドレス突合によるバクラクユーザーの特定

Slackから飛んでくるリクエストには、Slack Team IDやUser IDなどが含まれます。Slack Appからの操作は、利用者本人の操作としてバクラク勤怠のFirst-party APIを叩く必要があるため、User IDなどのSlackユーザー情報から対応するバクラクユーザーを特定する必要があります。 バクラク勤怠では管理者のみが有効化できるテナントレベルのSlack連携 (OAuth) と、従業員なら誰でも行えるSlack個人連携 (OAuth) を提供しています。従業員全員が明示的に個人連携をすれば、Slackユーザーからバクラクユーザーを一意に特定できますが、手間すぎるとも考えられました。そこで、すべてのケースは救えませんが、Slackユーザーとバクラクユーザーのメールアドレスの突合によるバクラクユーザーを特定を基本としました。users.info APIによりSlack User IDからメールアドレスを引くことができます。メールアドレスが一致している場合は、個人連携することなくSlack Appを利用できます。個人連携はメールアドレスが一致しない場合か、OAuth連携しなければ利用できない一部機能のためのものという位置づけにしています。

また、Slackのトークンの取得などでMySQLを扱う必要があったため、Goの小さいサービス (entrypoint) として実装しています1。特定後、そのバクラクユーザーとして認証トークンを払い出しておきます(authというサービスのRPCを呼ぶ)。

Bolt for JavaScript

これまでのバクラクシリーズに存在するSlack連携は、Goで書かれたサーバーで実装されています。しかしバクラク勤怠では、App Homeを筆頭に、複雑なviewの組み立てが多く、Goだと面倒が見込まれました。ネストが深い構造体を頑張って組み立てるのも、Go TemplateでJSONを記述するのもどちらも微妙そうでした。

そこでBolt for JavaScriptとTypeScriptを採用することにしました。これによりGoよりはかなり直感的かつ楽にviewやblockを組み立てることができます。また、Goでは slack-go/slack というcommunity-maintainedなライブラリからSlack APIを使っていたのに対し、Boltは公式のSDKであるところもポジティブな点です。

Web向けにNext.jsのサーバーが存在しており、一部ロジックの共有を楽にしたいことからNext.jsに埋め込む形で、Connect RPCとして以下のイメージで実装しました(デプロイメントは別)。

// src/server/rpc/handleSlackEvent.ts

import type { PartialMessage } from "@bufbuild/protobuf";
import type { HandlerContext } from "@connectrpc/connect";
import { logger } from "@layerone/logger";

import { HandleSlackEventResponse } from "../../lib/proto/__generated__/attendance_pb";
import type { HandleSlackEventRequest } from "../../lib/proto/__generated__/attendance_pb";
import { app } from "../slack/app";

export async function handleSlackEvent(
  ctx: HandlerContext,
  req: HandleSlackEventRequest,
  serviceToken?: string // auth token
): Promise<PartialMessage<HandleSlackEventResponse>> {
  let acked = false;
  const json = JSON.parse(req.payloadJson);

  // Wrap ack function in a promise because we need to response ack within 3 seconds
  const ackPromise = new Promise<object | string>((resolve, reject) => {
    const event = {
      body: json,
      customProperties: {
        serviceToken,
        ownedBotToken: req.ownedBotToken,
      },
      ack: async (response: object | string) => {
        acked = true;
        resolve(response);
      },
    };

    try {
      app.processEvent(event);
    } catch (err: unknown) {
      reject(err);
    }
  });

  try {
    const ackResponse = await ackPromise;

    if (!acked) {
      throw new Error("no event handler found");
    }

    return new HandleSlackEventResponse({
      // if storedResponse is object, we need to convert it to JSON string
      responseJson:
        typeof ackResponse === "object"
          ? JSON.stringify(ackResponse)
          : ackResponse,
    });
  } catch (err) {
    logger.error("Error processing Slack event:", { err });
    throw new Error("Internal Server Error");
  }
}
// src/server/slack/app.ts

import { logger } from "@layerone/logger";
import type {
  BlockAction,
  BlockElementAction,
  App,
} from "@slack/bolt";
import { WebClient } from "@slack/web-api";
import type { Context } from "../types";
import { graphqlRequest, buildGraphqlRequestHeader } from "../../lib/graphql";
import { slackActionId } from "../const";
import { SlackPunchMutation, SlackAppHomeQuery } from "../query";
import { HomeView } from "../jsx/home";

const app = new App({
  signingSecret: "dummy", // signingSecret is required, but we verify the signature manually
  // required but not used
  authorize: async (): Promise<any> => {
    return {};
  },
  processBeforeResponse: true,
});

app.action<BlockAction<BlockElementAction>, Context>(
  slackActionId.clockIn,
  async ({ ack, context, body }) => {
    await ack();
    try {
      await clockIn({
        context,
        userId: body.user.id,
      });
    } catch (err) {
      logger.error(`Error handling ${slackActionId.clockIn} action:`, {
        err,
      });
    }
  }
);

const clockIn = async ({
  context,
  userId,
}: {
  context: Context;
  userId: string;
}) => {
  await graphqlRequest(
    buildGraphqlRequestHeader(context),
    SlackPunchMutation,
    {
        "CLOCK_IN",
        punchChannel: "SLACK",
      }
    )
  );
  await renderHomeView({
    context,
    userId,
  });
};

const renderHomeView = async ({
  context,
  userId,
}: {
  context: Context;
  userId: string;
}) => {
  const data = await graphqlRequest(
    buildGraphqlRequestHeader(context),
    SlackAppHomeQuery,
  );
  const client = new WebClient(context.ownedBotToken);
  await client.views.publish({
    user_id: userId,
    view: HomeView(data);
  });
}

export { app };

特徴的な点は以下です。

  • Slackからのリクエストに対するAckは3秒以内に返さなければならないが、response_actionなどAck時に内容を指定しなければならないことがあるため、Ackの内容が決まった時点で即座にレスポンスする
  • 認証トークンなどをCustom propertiesに埋め込む。これによりBoltのハンドラ内では context.serviceToken などという形でアクセスできる
  • Slackからのリクエスト署名の検証はすでにentrypointサービスで行っているため processBeforeResponse: true にしつつ自前で app.processEvent() を呼ぶ

この handleSlackEvent RPCをentrypointサービスから呼ぶことでSlackイベントを処理します。RPCのペイロードにはSlackからのリクエスト内容をそのまま詰めています。

jsx-slack

yhatt/jsx-slackは、JSXでSlackのviewやblockの組み立てができるライブラリです。シンプルな構造の組み立てであればjsx-slack無しでもさほど問題にはなりませんが、複雑な出し分けをしたい場合は非常に役立ちます。その一例が、勤怠申請の承認機能における申請の表示です。申請の種類には打刻修正や休暇取得などがあります。一口に休暇といっても、実際には半休/全休・時間休・振替休日など多様です。また申請は基本的に1日に対して1つの設計なために、1つの申請には複数の申請内容が含まれることがあります。これをSlack上で出し分けるにあたり、JSXの記述力に助けられました。

Slack申請承認の表示例

上の図のうち、チェックボックス1つを組み立てるコードの様子は以下です。

function WorkflowInstanceApprovalCheckbox({
  instance,
}: {
  instance: WorkflowInstance;
}) {
  return JSXSlack(
    <Checkbox
      checked
      value={instance.id}
      description={<WorkflowInstanceDiff instance={instance} />}
    >
      <b>
        {formatDateStringToDate(instance.workDate)} &nbsp;&nbsp;
        {instance.openedTenantUser.name}
      </b>
    </Checkbox>
  );
}

function WorkflowInstanceDiff({
  instance,
}: {
  instance: WorkflowInstance;
}) {
  return (
    <>
      {instance.isModifyHolidayWork && (
        <WorkflowInstanceHolidayWorkDiff diff=(instance.diff.holidayWorkDiff) />
      )}
      { /* more elements ... */ }
    </>
  );
}

function WorkflowInnstanceHolidayWorkDiff({
  diff,
}: {
  diff: WorkflowInstanceHolidayWorkDiff | null;
) {
  if (!diff) {
    return null;
  }
  const emoji = ":rotating_light:";
  switch (diff.operation) {
    case "CREATE": {
      return <>{emoji} 休日出勤</>;
    }
    case "UPDATE": {
      const afterIsHolidayWork = diff.afterHolidayWork?.isHolidayWork;
      return afterIsHolidayWork ? (
        <>{emoji} 休日出勤</>
      ) : (
        <s>{emoji} 休日出勤</s>
      );
    }
    case "DELETE": {
      return <s>{emoji} 休日出勤</s>;
    }
  }
}

// more functions ...

GraphQL

設計当初から、App HomeではWebと同様に情報を見せていくことを考えていました。WebではGraphQLにより必要な情報の取得を楽にしており、App Homeでもこれに乗らない手はないと考えました。App Homeも実質的に「フロントエンド」であることを考えると、GraphQLを利用することに違和感はありませんでした。Webではuser-facingなGraphQLサーバーを利用しますが、これとは別のデプロイメントとなるVPC内のGraphQLサーバーを立て、これをBoltから利用しています。基本的にWebで実装されている機能をSlack連携にも追加していく方針のため、Slack側を実装する際にはすでに欲しいGraphQLのクエリが存在することが大半で、これを利用するだけなので実装効率が格段に良いです。

おわりに

この記事では、バクラク勤怠におけるSlack連携のアーキテクチャについて簡単に紹介しました。

元々自分はDevOpsチームに所属してきましたが、LayerX社内向けの勤怠Slackアプリを開発運用していたこともあり、去年の9月頃に勤怠チームからSlackアプリの開発を手伝ってくれないかとの話を受け、現在は勤怠チームに異動しそこでフルコミットしています。社内用の勤怠Slackアプリを作った最大の理由は、自分自身が楽に勤怠を付けたかったからでしたが、いつか「バクラク勤怠」が誕生することがあった場合になんらかの影響を与えたり、開発に関われたら面白いなと実は思ってもいました。それが現実になり、自分としては勝手にエモくなっています。今後も "バクラク" な価値を届け続けるべく、ユーザーからは直接見えないアーキテクチャなどもこだわり抜いていきたい所存です。

tech.layerx.co.jp

また、バクラク事業部では今後も新規事業を立ち上げていくため、ソフトウェアエンジニアを募集しています。自分自身が実際チームで開発してみて一番感じたのは、「スタートアップの熱量・空気感」でした。興味が少しでもある方はぜひご応募ください。

open.talentio.com


  1. バクラク事業部の標準スタックではGoからMySQLを利用するため



以上の内容はhttps://tech.layerx.co.jp/entry/2025/01/21/115503より取得しました。
このページはhttp://font.textar.tv/のウェブフォントを使用してます

不具合報告/要望等はこちらへお願いします。
モバイルやる夫Viewer Ver0.14