Daily Schedule Slack Bot V2 (with flex)

유영석·2023년 4월 27일
5

Daily Schedule Slack Bot

목록 보기
2/2

이번 회사에서도 Flex(HR 근태관리솔루션)를 도입하게 되었다. 스타트업답게 슬랙은 당연히 사용하고 있었다.

지난번에 만든 것을 그대로 가져와서 슬랙을 이롭게 해볼까! 싶었지만 Flex의 로그인 시스템이 대대적인 개선이 이루어졌는지 기존 로그인 로직과 응답구조가 전체적으로 개편되어 있었다. 분석하는데 굉장히 애를 먹었지만

또한 현재 회사는 특별한 근무 구조를 가지고 있어, 13-17시만 고정근무하고 주간 40시간을 자유롭게 채우는 형태로 되어있어서 기존처럼 특정시간에만 알람을 줄 수 없었다. 그래서 알람을 한번만 보내는 것이 아닌 특정 시간마다 실행하여 매일 여러번 업데이트를 하게 만들었다. 규칙은 간단하게 아침 9시에 첫번째 슬랙을 보내고 30분 간격으로 업데이트를 하게 했다.

슬랙 메시지

결과물을 만들기 위해 사용한 기술은 다음과 같다.

  • Node.js
  • AWS (Lambda, EventBridge, S3)
  • Slack API
  • Flex API (API 문서를 볼 수 없어 플랙스 홈페이지를 직접 디버깅 했다)

따로 Cron Job을 돌릴만한 컴퓨터가 없어서 Amazon EventBridge와 Lambda를 이용해 특정 시간에 기능을 실행할 수 있게 했다.

Amazon EventBridge는 Cron job schedule로 매일 오전 9시부터 30분 간격으로 Lambda를 실행한다.
0,30 0-14 ? * 2-6 *

Lambda에서 돌아가는 코드는 다음과 같은 실행 순서를 갖는다.

  1. Flex API를 이용하여 로그인하고 토큰을 획득한다.

    Flex 로그인은 challenge > identifier > authentication > password > authorization > customerUser > exchange를 거쳐 Access Token을 획득할 수 있게 되어있다. 기본적으로 Cookie에 내용을 담아 통신하는 형태로 보인다.
    투자 많이 받고 보안이 까다로워진것 같다. 부럽다... 우리 회사도 투자좀...

  2. Flex API를 이용하여 User List(회사 직원 정보)를 가져온다.
  3. Flex API를 이용하여 User별 스케쥴을 가져온다.
  4. 스케쥴을 근무형태(근무, 원격근무, 외근, 출장, 오전반차, 오후반차)에 맞춰 변형한다.

    근무 형태에 따라 workStartRecordType이라는 값이 달라지는데 API 문서가 없이 화면만으로 각각의 의미를 찾는데 좀 힘들었다.
    또한 Flex API 응답이 단순하게 근무를 시작한 경우와 시작/끝을 등록한 경우를 다르게 처리해 까다롭게 처리해야 했다.
    스케쥴은 시간에 따라 출근전/근무중/퇴근후 로 나뉘기 때문에 각각의 상태에 따라 시간을 잘 표시해줄 수 있게 처리해야 했다.

  5. 2.와 4.를 합쳐 User 별로 스케쥴을 구성한다.
  6. User의 팀 정보를 이용해 팀별로 User를 묶는다.
  7. 슬랙 메시지를 Post(혹은 Update)한다.
  8. 슬랙 메시지 Update를 위해 ts, channel을 s3에 저장한다.

전체코드는 다음과 같다.

const axios = require("axios");
const AWS = require("aws-sdk");

const ACCESS_KEY_ID = "";
const SECRET_ACCESS_KEY = "";

const challengeURL = "https://flex.team/api-public/v2/auth/challenge";
const identifierURL = "https://flex.team/api-public/v2/auth/verification/identifier";
const authenticationURL = "https://flex.team/api-public/v2/auth/authentication";
const passwordURL = "https://flex.team/api-public/v2/auth/authentication/password";
const authorizationURL = "https://flex.team/api-public/v2/auth/authorization";
const customerUserURL = "https://flex.team/api-public/v2/auth/tokens/customer-user";
const exchangeURL = "https://flex.team/api-public/v2/auth/tokens/customer-user/exchange";

const workSchedulesURL = "https://flex.team/api/v2/time-tracking/users/work-schedules";

const customerIdHash = ""; // 회사 customerIdHash, Flex API를 디버깅해보면 본인 회사코드를 알수 있을 것이다. 10자리의 영숫자로 되어있다.
const searchUsersURL = `https://flex.team/action/v2/search/customers/${customerIdHash}/search-users`;

const slackPostMessageURL = "https://slack.com/api/chat.postMessage";
const slackUpdateMessageURL = "https://slack.com/api/chat.update";

AWS.config.update({
  region: "ap-northeast-2",
  credentials: {
    accessKeyId: ACCESS_KEY_ID,
    secretAccessKey: SECRET_ACCESS_KEY,
  },
});

const Bucket = "Bucket-Name";
const Key = "daily-check-in.json";
const s3 = new AWS.S3({ params: { Bucket } });

const timetable = async () => {
  const now = new Date();

  const isWeekend = new Date().getDay() === 0 || new Date().getDay() === 6;
  if (isWeekend) return; // 주말 예외 처리
  
  const challenge = await axios
    .post(challengeURL, {
      deviceInfo: { os: "web", osVersion: "", appVersion: "" },
      locationInfo: {},
    })
    .then(({ data }) => data);

  // console.log(challenge.sessionId);

  await axios
    .post(
      identifierURL,
      { identifier: "" }, // Login Email
      {
        headers: {
          cookie: `FlexTeam-Version=V2;FlexTeam-Locale=ko;`,
          "flexteam-v2-login-session-id": challenge.sessionId,
        },
      }
    )
    .then(({ data }) => data);

  // console.log(identifier);

  const authentication = await axios
    .get(authenticationURL, {
      headers: {
        cookie: `FlexTeam-Version=V2;FlexTeam-Locale=ko;`,
        "flexteam-v2-login-session-id": challenge.sessionId,
      },
    })
    .then(({ data }) => data);

  // console.log(authentication);

  const password = await axios
    .post(
      passwordURL,
      { password: "" }, // Login Password
      {
        headers: {
          cookie: `FlexTeam-Version=V2;FlexTeam-Locale=ko;`,
          "flexteam-v2-login-session-id": challenge.sessionId,
        },
      }
    )
    .then(({ data }) => data);

  // console.log(password);

  const authorization = await axios
    .post(
      authorizationURL,
      {},
      {
        headers: {
          cookie: `FlexTeam-Version=V2;FlexTeam-Locale=ko;`,
          "flexteam-v2-login-session-id": challenge.sessionId,
        },
      }
    )
    .then(({ data }) => data);

  // console.log(authorization.v2Response.workspaceToken);

  const customerUser = await axios
    .get(customerUserURL, {
      headers: {
        cookie: `FlexTeam-Version=V2;FlexTeam-Locale=ko;`,
        "flexteam-v2-workspace-access": authorization.v2Response.workspaceToken.accessToken.token,
      },
    })
    .then(({ data }) => data[0]);

  // console.log(customerUser);

  const exchange = await axios
    .post(exchangeURL, customerUser, {
      headers: {
        cookie: `FlexTeam-Version=V2;FlexTeam-Locale=ko;`,
        "flexteam-v2-workspace-access": authorization.v2Response.workspaceToken.accessToken.token,
      },
    })
    .then(({ data }) => data);

  const AID = exchange.token;

  const today = new Date();
  today.setHours(0);
  today.setMinutes(0);
  today.setSeconds(0);
  today.setMilliseconds(0);
  const tomorrow = new Date(today);
  tomorrow.setDate(today.getDate() + 1);

  // 전문연 등으로 인한 실제팀과 플랙스 팀이름이 약간 다른 경우를 위해
  const getTeamName = (teamName) => {
    switch (teamName) {
      case "":
        return "";
      default:
        return teamName;
    }
  };

  const users = await axios
    .post(
      searchUsersURL + "?size=50",
      {
        filter: {
          departmentIdHashes: [],
          userStatuses: [
            "LEAVE_OF_ABSENCE",
            "LEAVE_OF_ABSENCE_SCHEDULED",
            "RESIGNATION_SCHEDULED",
            "IN_EMPLOY",
            "IN_APPRENTICESHIP",
          ],
        },
      },
      { headers: { cookie: `AID=${AID};` } }
    )
    .then(({ data }) =>
      data.list.map(({ user }) => ({
        userIdHash: user.userIdHash,
        name: user.name,
        departmentName: getTeamName(user.positions[0].department.name),
      }))
    );

  // console.log(users);

  const userIdHashParam = users.map((user) => `userIdHashes=${user.userIdHash}`).join("&");
  const workSchedules = await axios
    .get(
      workSchedulesURL +
        `?${userIdHashParam}&timeStampFrom=${today.valueOf()}&timeStampTo=${tomorrow.valueOf()}`,
      { headers: { cookie: `AID=${AID};` } }
    )
    .then(({ data }) => {
      const day = today.getDay() - 1; // 일월화수목금토 -> 월화수목금토일로 인덱스 수정, 토일은 위에서 예외처리 되었기때문에 -1만해도 괜찮다.

      // console.log(data.workScheduleResults);

      const workScheduleResults = data.workScheduleResults.map((workSchedule) => ({
        userIdHash: workSchedule.userIdHash,

        workType:
          workSchedule.days[day].workRecords[workSchedule.days[day].workRecords?.length - 1 || 0]
            ?.name,
        blockTimeFrom: workSchedule.days[day].workRecords[0]?.blockTimeFrom.timeStamp,
        blockTimeTo:
          workSchedule.days[day].workRecords[workSchedule.days[day].workRecords.length - 1]
            ?.blockTimeTo.timeStamp,
        workStartRecordType:
          workSchedule.days[day].workStartRecords[
            workSchedule.days[day].workStartRecords?.length - 1 || 0
          ]?.customerWorkFormId,
        workStartRecordFrom: workSchedule.days[day].workStartRecords[0]?.blockTimeFrom?.timeStamp,
        timeOffType: workSchedule.days[day].timeOffs[0]?.timeOffRegisterUnit,
        timeOffBlockTimeFrom: workSchedule.days[day].timeOffs[0]?.blockTimeFrom?.timeStamp,
        timeOffBlockTimeTo: workSchedule.days[day].timeOffs[0]?.blockTimeTo?.timeStamp,
      }));

      return workScheduleResults.map((obj) => {
        // workType 재정의
        if (obj.timeOffType === "DAY") obj.workType = "휴가";
        else if (
          obj.timeOffType === "HALF_DAY_PM" && // 오후반차이고
          obj.timeOffBlockTimeFrom <= now // 오후반차 시작시간보다 크면
        )
          obj.workType = "휴가";
        else if (
          obj.timeOffType === "HALF_DAY_AM" && // 오전반차이고
          obj.timeOffBlockTimeFrom <= now && // 현재가 해당 시간이라면
          now <= obj.timeOffBlockTimeTo
        )
          obj.workType = "휴가";
        else if (obj.workStartRecordType === "85611") obj.workType = "근무";
        else if (obj.workStartRecordType === "85613") obj.workType = "외근";
        else if (obj.workStartRecordType === "85614") obj.workType = "원격 근무";
        else if (obj.workStartRecordType === "85615") obj.workType = "출장";

        // blockTimeFrom 재정의.
        obj.blockTimeFrom = obj.blockTimeFrom || obj.workStartRecordFrom;

        return obj;
      });
    });

  // console.log(workSchedules);

  const mergeUserInformation = users.reduce((acc, obj) => {
    acc[obj.userIdHash] = obj;

    return acc;
  }, {});

  workSchedules.forEach((workSchedule) => {
    mergeUserInformation[workSchedule.userIdHash] = {
      ...mergeUserInformation[workSchedule.userIdHash],
      ...workSchedule,
    };
  });

  // console.log(mergeUserInformation);

  const groupByDepartment = Object.entries(mergeUserInformation).reduce((acc, [_, user]) => {
    const key = user.departmentName;
    if (!acc[key]) acc[key] = [];
    acc[key].push(user);

    return acc;
  }, {});

  // console.log(groupByDepartment);

  const getEmoji = (workType) => {
    switch (workType) {
      case "근무":
        return "office";
      case "원격 근무":
        return "heads-down";
      case "외근":
        return "taxi";
      case "휴가":
        return "beach_with_umbrella";
      case "출장":
        return "airplane";
    }
  };

  const getTimeString = (type, from, to) => {
    if (type === "휴가") return "";
    else if (to === undefined)
      return `\`${new Date(from).toLocaleTimeString("ko-KR", { timeZone: "asia/seoul" })} ~ \``;
    else
      return `\`${new Date(from).toLocaleTimeString("ko-KR", {
        timeZone: "asia/seoul",
      })} ~ ${new Date(to).toLocaleTimeString("ko-KR", { timeZone: "asia/seoul" })}\``;
  };

  const diffHour = (date1, date2) => {
    const diff = new Date(date2).valueOf() - new Date(date1).valueOf();
    const diffInHours = diff / 1000 / 60 / 60;

    return diffInHours.toFixed(2);
  };

  let message = `>*${formatDate()} 데일리 체크인*\n:office: 사무실 :heads-down: 원격 근무 :taxi: 외근 :airplane: 출장 :beach_with_umbrella: 휴가\n`;

  // 슬랙 메시지에 표시될 팀명 정렬을 위한 팀 이름 리스트
  const departmentNames = [
    "개발팀",
    "디자인팀",
    "아무개팀",
  ];

  departmentNames.forEach((departmentName) => {
    const users = groupByDepartment[departmentName];

    if (!users) return;

    if (users.filter((user) => user.workType !== undefined).length === 0) return;

    message += `>${departmentName}\n`;
    users
      .filter((user) => user.workType !== undefined)

      .forEach((user) => {
        message += `:${getEmoji(user.workType)}: ${user.name}${getTimeString(
          user.workType,
          user.blockTimeFrom,
          user.blockTimeTo
        )} ${
          user.workType !== "휴가" && user.blockTimeTo
            ? `(Day: ${diffHour(user.blockTimeFrom, user.blockTimeTo)}h)`
            : ""
        } \n`;
      });

    message += "\n";
  });

  const response = await s3.getObject({ Bucket, Key }).promise();
  const { ts, channel } = JSON.parse(response.Body?.toString("utf-8"));

  const tsDate = new Date(ts * 1000);
  const needPost = tsDate.getDate() !== now.getDate(); // 당일인지 체크 후 메세지를 보내거나 수정한다.

  if (needPost) {
    const slackMessage = await axios
      .post(
        slackPostMessageURL,
        { channel: "daily_check-in", text: message, invalid_charset: "UTF-8" },
        {
          headers: {
            "Content-type": "application/json; charset=utf-8",
            Authorization: "슬랙토큰",
          },
        }
      )
      .then(({ data }) => data);

    await s3
      .putObject({
        Bucket,
        Key,
        Body: JSON.stringify({ ts: slackMessage.ts, channel: slackMessage.channel }),
        CacheControl: "no-store",
      })
      .promise();
  } else {
    const slackMessage = await axios
      .post(
        slackUpdateMessageURL,
        { channel, text: message, invalid_charset: "UTF-8", ts },
        {
          headers: {
            "Content-type": "application/json; charset=utf-8",
            Authorization: "슬랙토큰",
          },
        }
      )
      .then(({ data }) => data);
  }
};

function formatDate(date = new Date()) {
  const d = date instanceof Date ? date : new Date();
  let month = "" + (d.getMonth() + 1);
  let day = "" + d.getDate();
  const year = d.getFullYear();

  if (month.length < 2) month = "0" + month;
  if (day.length < 2) day = "0" + day;

  return [year, month, day].join(".");
}

exports.handler = async (object) => {
  await timetable();

  let response = { statusCode: 200 };
  return response;
};

코드가 너무 너무 길다...

제작하면서 신경쓴 점

  • 플랙스 로그인 너무너무 어려웠습니다. 분석만 몇시간 걸린듯ㅜㅜ
  • 회사의 근무 형태가 자유로워 그에 맞춰 처리하기 위한 로직

느낀점

플랙스 홈페이지 들어가면 로딩이 너무 느려서 사람들 데이터 보기가 너무 힘들었고 근무정보를 제대로 볼수가 없어서 불편했는데, 슬랙으로 편하게 볼 수 있어서 맘에 든다 :)

profile
ENFP FE 개발자 :)

1개의 댓글

comment-user-thumbnail
2023년 4월 27일

1빠

답글 달기