From be16b72400a5112799b979eaa32ace0f253f8e02 Mon Sep 17 00:00:00 2001 From: Cheng CHENG Date: Sat, 6 Aug 2022 01:56:20 +0800 Subject: [PATCH] feat: support lark calendar (#3019) * feat: support lark calendar * New Crowdin translations by Github Action (#3016) Co-authored-by: Crowdin Bot Co-authored-by: Peer Richelsen * fix: lark calendar get app ticket bugs * feat: add send msg service for lark bot * fix: comment on PR of lark-calendar * chore: update lark bot message * Refactors add endpoint * Adds missing GET endpoint handler * Update yarn.lock * fix: comments * Update yarn.lock * fix: comment about inferred type * Added fetcher helper * Update yarn.lock Co-authored-by: chengcheng.frontend Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: Crowdin Bot Co-authored-by: Peer Richelsen Co-authored-by: zomars --- .env.appStore.example | 8 + app.json | 10 +- packages/app-store/apps.browser.generated.tsx | 3 + packages/app-store/apps.server.generated.ts | 1 + packages/app-store/index.ts | 2 + packages/app-store/larkcalendar/README.mdx | 8 + packages/app-store/larkcalendar/_metadata.ts | 25 ++ packages/app-store/larkcalendar/api/add.ts | 50 +++ .../app-store/larkcalendar/api/callback.ts | 100 +++++ packages/app-store/larkcalendar/api/events.ts | 122 ++++++ packages/app-store/larkcalendar/api/index.ts | 3 + packages/app-store/larkcalendar/common.ts | 26 ++ .../components/InstallAppButton.tsx | 18 + .../larkcalendar/components/index.ts | 1 + packages/app-store/larkcalendar/index.ts | 3 + .../larkcalendar/lib/AppAccessToken.ts | 155 +++++++ .../app-store/larkcalendar/lib/BotService.ts | 130 ++++++ .../larkcalendar/lib/CalendarService.ts | 390 ++++++++++++++++++ packages/app-store/larkcalendar/lib/index.ts | 1 + packages/app-store/larkcalendar/package.json | 15 + .../app-store/larkcalendar/static/icon.svg | 5 + .../larkcalendar/types/LarkCalendar.ts | 160 +++++++ packages/prisma/seed-app-store.ts | 11 + yarn.lock | 109 ++++- 24 files changed, 1330 insertions(+), 26 deletions(-) create mode 100644 packages/app-store/larkcalendar/README.mdx create mode 100644 packages/app-store/larkcalendar/_metadata.ts create mode 100644 packages/app-store/larkcalendar/api/add.ts create mode 100644 packages/app-store/larkcalendar/api/callback.ts create mode 100644 packages/app-store/larkcalendar/api/events.ts create mode 100644 packages/app-store/larkcalendar/api/index.ts create mode 100644 packages/app-store/larkcalendar/common.ts create mode 100644 packages/app-store/larkcalendar/components/InstallAppButton.tsx create mode 100644 packages/app-store/larkcalendar/components/index.ts create mode 100644 packages/app-store/larkcalendar/index.ts create mode 100644 packages/app-store/larkcalendar/lib/AppAccessToken.ts create mode 100644 packages/app-store/larkcalendar/lib/BotService.ts create mode 100644 packages/app-store/larkcalendar/lib/CalendarService.ts create mode 100644 packages/app-store/larkcalendar/lib/index.ts create mode 100644 packages/app-store/larkcalendar/package.json create mode 100644 packages/app-store/larkcalendar/static/icon.svg create mode 100644 packages/app-store/larkcalendar/types/LarkCalendar.ts diff --git a/.env.appStore.example b/.env.appStore.example index 886b3d3024..921e46f5bf 100644 --- a/.env.appStore.example +++ b/.env.appStore.example @@ -88,3 +88,11 @@ VITAL_REGION="us" # @see https://github.com/calcom/cal.com/blob/main/packages/app-store/zapier/README.md ZAPIER_INVITE_LINK="" # ********************************************************************************************************* + +# - LARK +# Needed to enable Lark Calendar integration and Login with Lark +# @see +LARK_OPEN_APP_ID="" +LARK_OPEN_APP_SECRET="" +LARK_OPEN_VERIFICATION_TOKEN="" +# ********************************************************************************************************* diff --git a/app.json b/app.json index f7e18c835b..2ad52b916c 100644 --- a/app.json +++ b/app.json @@ -3,15 +3,7 @@ "description": "Open Source Scheduling", "repository": "https://github.com/calcom/cal.com", "logo": "https://cal.com/android-chrome-512x512.png", - "keywords": [ - "react", - "typescript", - "node", - "nextjs", - "prisma", - "postgres", - "trpc" - ], + "keywords": ["react", "typescript", "node", "nextjs", "prisma", "postgres", "trpc"], "addons": [ { "plan": "heroku-postgresql:hobby-dev" diff --git a/packages/app-store/apps.browser.generated.tsx b/packages/app-store/apps.browser.generated.tsx index 4165cf6111..cad5c3b913 100644 --- a/packages/app-store/apps.browser.generated.tsx +++ b/packages/app-store/apps.browser.generated.tsx @@ -17,6 +17,7 @@ import { metadata as googlevideo_meta } from "./googlevideo/_metadata"; import { metadata as hubspotothercalendar_meta } from "./hubspotothercalendar/_metadata"; import { metadata as huddle01video_meta } from "./huddle01video/_metadata"; import { metadata as jitsivideo_meta } from "./jitsivideo/_metadata"; +import { metadata as larkcalendar_meta } from "./larkcalendar/_metadata"; import { metadata as metamask_meta } from "./metamask/_metadata"; import { metadata as office365calendar_meta } from "./office365calendar/_metadata"; import { metadata as office365video_meta } from "./office365video/_metadata"; @@ -44,6 +45,7 @@ export const appStoreMetadata = { hubspotothercalendar: hubspotothercalendar_meta, huddle01video: huddle01video_meta, jitsivideo: jitsivideo_meta, + larkcalendar: larkcalendar_meta, metamask: metamask_meta, office365calendar: office365calendar_meta, office365video: office365video_meta, @@ -68,6 +70,7 @@ export const InstallAppButtonMap = { hubspotothercalendar: dynamic(() => import("./hubspotothercalendar/components/InstallAppButton")), huddle01video: dynamic(() => import("./huddle01video/components/InstallAppButton")), jitsivideo: dynamic(() => import("./jitsivideo/components/InstallAppButton")), + larkcalendar: dynamic(() => import("./larkcalendar/components/InstallAppButton")), metamask: dynamic(() => import("./metamask/components/InstallAppButton")), office365calendar: dynamic(() => import("./office365calendar/components/InstallAppButton")), office365video: dynamic(() => import("./office365video/components/InstallAppButton")), diff --git a/packages/app-store/apps.server.generated.ts b/packages/app-store/apps.server.generated.ts index 4eab3608ec..fd003ee35d 100644 --- a/packages/app-store/apps.server.generated.ts +++ b/packages/app-store/apps.server.generated.ts @@ -14,6 +14,7 @@ export const apiHandlers = { hubspotothercalendar: import("./hubspotothercalendar/api"), huddle01video: import("./huddle01video/api"), jitsivideo: import("./jitsivideo/api"), + larkcalendar: import("./larkcalendar/api"), metamask: import("./metamask/api"), office365calendar: import("./office365calendar/api"), office365video: import("./office365video/api"), diff --git a/packages/app-store/index.ts b/packages/app-store/index.ts index d3de3c05a0..67896296d2 100644 --- a/packages/app-store/index.ts +++ b/packages/app-store/index.ts @@ -10,6 +10,7 @@ import * as googlevideo from "./googlevideo"; import * as hubspotothercalendar from "./hubspotothercalendar"; import * as huddle01video from "./huddle01video"; import * as jitsivideo from "./jitsivideo"; +import * as larkcalendar from "./larkcalendar"; import * as metamask from "./metamask"; import * as office365calendar from "./office365calendar"; import * as office365video from "./office365video"; @@ -31,6 +32,7 @@ const appStore = { hubspotothercalendar, huddle01video, jitsivideo, + larkcalendar, office365calendar, office365video, slackmessaging, diff --git a/packages/app-store/larkcalendar/README.mdx b/packages/app-store/larkcalendar/README.mdx new file mode 100644 index 0000000000..3b01d8b44c --- /dev/null +++ b/packages/app-store/larkcalendar/README.mdx @@ -0,0 +1,8 @@ +--- +items: + - /api/app-store/larkcalendar/icon.svg +--- + + + +Lark Calendar is a time management and scheduling service developed by Lark. Allows users to create and edit events, with options available for type and time. Available to anyone that has a Lark account on both mobile and web versions. diff --git a/packages/app-store/larkcalendar/_metadata.ts b/packages/app-store/larkcalendar/_metadata.ts new file mode 100644 index 0000000000..572c7d9245 --- /dev/null +++ b/packages/app-store/larkcalendar/_metadata.ts @@ -0,0 +1,25 @@ +import type { App } from "@calcom/types/App"; + +import _package from "./package.json"; + +export const metadata = { + name: "Lark Calendar", + description: _package.description, + installed: true, + type: "lark_calendar", + title: "Lark Calendar", + imageSrc: "/api/app-store/larkcalendar/icon.svg", + variant: "calendar", + category: "calendar", + logo: "/api/app-store/larkcalendar/icon.svg", + publisher: "Cal.com", + rating: 5, + reviews: 69, + slug: "lark-calendar", + trending: false, + url: "https://cal.com/", + verified: true, + email: "help@cal.com", +} as App; + +export default metadata; diff --git a/packages/app-store/larkcalendar/api/add.ts b/packages/app-store/larkcalendar/api/add.ts new file mode 100644 index 0000000000..40b0ef79de --- /dev/null +++ b/packages/app-store/larkcalendar/api/add.ts @@ -0,0 +1,50 @@ +import type { NextApiRequest } from "next"; +import { stringify } from "querystring"; +import { z } from "zod"; + +import { WEBAPP_URL } from "@calcom/lib/constants"; +import { defaultHandler, defaultResponder } from "@calcom/lib/server"; + +import { encodeOAuthState } from "../../_utils/encodeOAuthState"; +import getAppKeysFromSlug from "../../_utils/getAppKeysFromSlug"; +import { LARK_HOST } from "../common"; + +const larkKeysSchema = z.object({ + app_id: z.string(), + app_secret: z.string(), +}); + +async function getHandler(req: NextApiRequest) { + const appKeys = await getAppKeysFromSlug("lark-calendar"); + const { app_secret, app_id } = larkKeysSchema.parse(appKeys); + + const state = encodeOAuthState(req); + + const params = { + app_id, + redirect_uri: WEBAPP_URL + "/api/integrations/larkcalendar/callback", + state, + }; + + const query = stringify(params); + + const url = `https://${LARK_HOST}/open-apis/authen/v1/index?${query}`; + + // trigger app_ticket_immediately + fetch(`https://${LARK_HOST}/open-apis/auth/v3/app_ticket/resend`, { + method: "POST", + headers: { + "Content-Type": "application/json; charset=utf-8", + }, + body: JSON.stringify({ + app_id, + app_secret, + }), + }); + + return { url }; +} + +export default defaultHandler({ + GET: Promise.resolve({ default: defaultResponder(getHandler) }), +}); diff --git a/packages/app-store/larkcalendar/api/callback.ts b/packages/app-store/larkcalendar/api/callback.ts new file mode 100644 index 0000000000..45d9917271 --- /dev/null +++ b/packages/app-store/larkcalendar/api/callback.ts @@ -0,0 +1,100 @@ +import type { NextApiRequest, NextApiResponse } from "next"; +import { z } from "zod"; + +import { getSafeRedirectUrl } from "@calcom/lib/getSafeRedirectUrl"; +import logger from "@calcom/lib/logger"; +import { defaultHandler, defaultResponder } from "@calcom/lib/server"; +import prisma from "@calcom/prisma"; + +import { decodeOAuthState } from "../../_utils/decodeOAuthState"; +import { LARK_HOST } from "../common"; +import { getAppAccessToken } from "../lib/AppAccessToken"; +import type { LarkAuthCredentials } from "../types/LarkCalendar"; + +const log = logger.getChildLogger({ prefix: [`[[lark/api/callback]`] }); + +const callbackQuerySchema = z.object({ + code: z.string().min(1), +}); + +async function getHandler(req: NextApiRequest, res: NextApiResponse) { + const { code } = callbackQuerySchema.parse(req.query); + const state = decodeOAuthState(req); + + try { + const appAccessToken = await getAppAccessToken(); + + const response = await fetch(`https://${LARK_HOST}/open-apis/authen/v1/access_token`, { + method: "POST", + headers: { + Authorization: "Bearer " + appAccessToken, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + grant_type: "authorization_code", + code, + }), + }); + + const responseBody = await response.json(); + + if (!response.ok || responseBody.code !== 0) { + log.error("get user_access_token failed with none 0 code", responseBody); + return res.redirect("/apps/installed?error=" + JSON.stringify(responseBody)); + } + + const key: LarkAuthCredentials = { + expiry_date: Math.round(+new Date() / 1000 + responseBody.data.expires_in), + access_token: responseBody.data.access_token, + refresh_token: responseBody.data.refresh_token, + refresh_expires_date: Math.round(+new Date() / 1000 + responseBody.data.refresh_expires_in), + }; + + /** + * A user can have only one pair of refresh_token and access_token effective + * at same time. Newly created refresh_token and access_token will invalidate + * older ones. So we need to keep only one lark credential per user only. + * However, a user may connect many times, since both userId and type are + * not unique in schema, so we have to use credential id as index for looking + * for the unique access_token token. In this case, id does not exist before created, so we cannot use credential id (which may not exist) as where statement + */ + const currentCredential = await prisma.credential.findFirst({ + where: { + userId: req.session?.user.id, + type: "lark_calendar", + }, + }); + + if (!currentCredential) { + await prisma.credential.create({ + data: { + type: "lark_calendar", + key, + userId: req.session?.user.id, + appId: "lark-calendar", + }, + }); + } else { + await prisma.credential.update({ + data: { + type: "lark_calendar", + key, + userId: req.session?.user.id, + appId: "lark-calendar", + }, + where: { + id: currentCredential.id, + }, + }); + } + + res.redirect(getSafeRedirectUrl(state?.returnTo) ?? "/apps/installed"); + } catch (error) { + log.error("handle callback error", error); + res.redirect(state?.returnTo ?? "/apps/installed"); + } +} + +export default defaultHandler({ + GET: Promise.resolve({ default: defaultResponder(getHandler) }), +}); diff --git a/packages/app-store/larkcalendar/api/events.ts b/packages/app-store/larkcalendar/api/events.ts new file mode 100644 index 0000000000..30d1822853 --- /dev/null +++ b/packages/app-store/larkcalendar/api/events.ts @@ -0,0 +1,122 @@ +import type { NextApiRequest, NextApiResponse } from "next"; +import { z } from "zod"; + +import logger from "@calcom/lib/logger"; +import { defaultHandler, defaultResponder } from "@calcom/lib/server"; +import prisma from "@calcom/prisma"; + +import { getAppKeys } from "../common"; +import { sendPostMsg } from "../lib/BotService"; + +const log = logger.getChildLogger({ prefix: [`[lark/api/events]`] }); + +const larkKeysSchema = z.object({ + open_verification_token: z.string(), +}); + +const appTicketEventsReqSchema = z.object({ + body: z.object({ + event: z.object({ + app_ticket: z.string().min(1), + }), + }), +}); + +const imMessageReceivedEventsReqSchema = z.object({ + body: z.object({ + header: z.object({ + tenant_key: z.string().min(1), + }), + event: z.object({ + sender: z.object({ + sender_id: z.object({ + open_id: z.string().min(1), + }), + }), + }), + }), +}); + +const p2pChatCreateEventsReqSchema = z.object({ + body: z.object({ + tenant_key: z.string().min(1), + event: z.object({ + user: z.object({ + open_id: z.string().min(1), + }), + }), + }), +}); + +async function postHandler(req: NextApiRequest, res: NextApiResponse) { + log.debug("receive events", req.body); + const appKeys = await getAppKeys(); + const { open_verification_token } = larkKeysSchema.parse(appKeys); + + // used for events handler binding in lark open platform, see + // https://open.larksuite.com/document/ukTMukTMukTM/uUTNz4SN1MjL1UzM?lang=en-US + if (req.body.type === "url_verification") { + return res.status(200).json({ challenge: req.body.challenge }); + } + + // used for receiving app_ticket, see + // https://open.larksuite.com/document/uAjLw4CM/ukTMukTMukTM/application-v6/event/app_ticket-events + if (req.body.event?.type === "app_ticket" && open_verification_token === req.body.token) { + const { + body: { + event: { app_ticket: appTicket }, + }, + } = appTicketEventsReqSchema.parse(req); + + await prisma.app.update({ + where: { slug: "lark-calendar" }, + data: { + keys: { + ...appKeys, + app_ticket: appTicket, + }, + }, + }); + return res.status(200).json({ code: 0, msg: "success" }); + } + + // used for handle user at bot in lark chat with cal.com connector bot, see + // https://open.larksuite.com/document/uAjLw4CM/ukTMukTMukTM/reference/im-v1/message/events/receive + if (req.body.header?.event_type === "im.message.receive_v1") { + const { + body: { + header: { tenant_key: tenantKey }, + event: { + sender: { + sender_id: { open_id: senderOpenId }, + }, + }, + }, + } = imMessageReceivedEventsReqSchema.parse(req); + + sendPostMsg(tenantKey, senderOpenId); + + return res.status(200).json({ code: 0, msg: "success" }); + } + + // used for handle user first talk with cal.com connector bot, see + // https://open.larksuite.com/document/ukTMukTMukTM/uYDNxYjL2QTM24iN0EjN/bot-events + if (req.body.event?.type === "p2p_chat_create") { + const { + body: { + tenant_key: tenantKey, + event: { + user: { open_id: senderOpenId }, + }, + }, + } = p2pChatCreateEventsReqSchema.parse(req); + + sendPostMsg(tenantKey, senderOpenId); + + return res.status(200).json({ code: 0, msg: "success" }); + } +} + +export default defaultHandler({ + POST: Promise.resolve({ default: defaultResponder(postHandler) }), +}); diff --git a/packages/app-store/larkcalendar/api/index.ts b/packages/app-store/larkcalendar/api/index.ts new file mode 100644 index 0000000000..8745e9ffa4 --- /dev/null +++ b/packages/app-store/larkcalendar/api/index.ts @@ -0,0 +1,3 @@ +export { default as add } from "./add"; +export { default as callback } from "./callback"; +export { default as events } from "./events"; diff --git a/packages/app-store/larkcalendar/common.ts b/packages/app-store/larkcalendar/common.ts new file mode 100644 index 0000000000..f00e0a9628 --- /dev/null +++ b/packages/app-store/larkcalendar/common.ts @@ -0,0 +1,26 @@ +import logger from "@calcom/lib/logger"; + +import getAppKeysFromSlug from "../_utils/getAppKeysFromSlug"; +import { LarkAppKeys } from "./types/LarkCalendar"; + +export const LARK_HOST = "open.larksuite.com"; + +export const getAppKeys = () => getAppKeysFromSlug("lark-calendar") as Promise; + +export const isExpired = (expiryDate: number) => + !expiryDate || expiryDate < Math.round(Number(new Date()) / 1000); + +export async function handleLarkError( + response: Response, + log: typeof logger +): Promise { + const data: T = await response.json(); + if (!response.ok || data.code !== 0) { + log.error("lark error with error: ", data, ", logid is:", response.headers.get("X-Tt-Logid")); + log.debug("lark request with data", data); + throw data; + } + log.info("lark request with logid:", response.headers.get("X-Tt-Logid")); + log.debug("lark request with data", data); + return data; +} diff --git a/packages/app-store/larkcalendar/components/InstallAppButton.tsx b/packages/app-store/larkcalendar/components/InstallAppButton.tsx new file mode 100644 index 0000000000..fb58420a30 --- /dev/null +++ b/packages/app-store/larkcalendar/components/InstallAppButton.tsx @@ -0,0 +1,18 @@ +import type { InstallAppButtonProps } from "@calcom/app-store/types"; + +import useAddAppMutation from "../../_utils/useAddAppMutation"; + +export default function InstallAppButton(props: InstallAppButtonProps) { + const mutation = useAddAppMutation("lark_calendar"); + + return ( + <> + {props.render({ + onClick() { + mutation.mutate(""); + }, + loading: mutation.isLoading, + })} + + ); +} diff --git a/packages/app-store/larkcalendar/components/index.ts b/packages/app-store/larkcalendar/components/index.ts new file mode 100644 index 0000000000..0d6008d4ca --- /dev/null +++ b/packages/app-store/larkcalendar/components/index.ts @@ -0,0 +1 @@ +export { default as InstallAppButton } from "./InstallAppButton"; diff --git a/packages/app-store/larkcalendar/index.ts b/packages/app-store/larkcalendar/index.ts new file mode 100644 index 0000000000..5373eb04ef --- /dev/null +++ b/packages/app-store/larkcalendar/index.ts @@ -0,0 +1,3 @@ +export * as api from "./api"; +export * as lib from "./lib"; +export { metadata } from "./_metadata"; diff --git a/packages/app-store/larkcalendar/lib/AppAccessToken.ts b/packages/app-store/larkcalendar/lib/AppAccessToken.ts new file mode 100644 index 0000000000..32acfd1605 --- /dev/null +++ b/packages/app-store/larkcalendar/lib/AppAccessToken.ts @@ -0,0 +1,155 @@ +import { z } from "zod"; + +import logger from "@calcom/lib/logger"; +import prisma from "@calcom/prisma"; + +import { LARK_HOST, getAppKeys, isExpired } from "../common"; + +const log = logger.getChildLogger({ prefix: [`[[LarkAppCredential]`] }); + +function makePoolingPromise( + promiseCreator: () => Promise, + times = 24, + delay = 5 * 1000 +): Promise { + return new Promise((resolve, reject) => { + promiseCreator() + .then(resolve) + .catch((err) => { + if (times <= 0) { + reject(err); + return; + } + setTimeout(() => { + makePoolingPromise(promiseCreator, times - 1, delay) + .then(resolve) + .catch(reject); + }, delay); + }); + }); +} + +const appKeysSchema = z.object({ + app_id: z.string().min(1), + app_secret: z.string().min(1), +}); + +const getValidAppKeys = async (): Promise> => { + const appKeys = await getAppKeys(); + const validAppKeys = appKeysSchema.parse(appKeys); + return validAppKeys; +}; + +const getAppTicketFromKeys = async (): Promise => { + const appKeys = await getValidAppKeys(); + const appTicketNew = appKeys?.app_ticket; + if (appTicketNew) { + return appTicketNew; + } + throw Error("lark appTicketNew not found in getAppTicketFromKeys"); +}; + +const getAppTicket = async (): Promise => { + log.debug("get app ticket invoked"); + const appKeys = await getValidAppKeys(); + + if (typeof appKeys.app_ticket === "string" && appKeys.app_ticket !== "") { + log.debug("has app ticket", appKeys.app_ticket); + return appKeys.app_ticket; + } + + /** + * Trigger app-ticket resend. app ticket can only be obtained from + * app_ticket event. + * see https://open.larksuite.com/document/uAjLw4CM/ukTMukTMukTM/application-v6/event/app_ticket-events + */ + log.info("Invoke app-ticket resend", appKeys.app_ticket); + + fetch(`https://${LARK_HOST}/open-apis/auth/v3/app_ticket/resend`, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + app_id: appKeys.app_id, + app_secret: appKeys.app_secret, + }), + }); + + /** + * 1. App_ticket is only valid for 1 hr. + * 2. The we cannot retrieve app_ticket by calling a API. + * 3. App_ticket can only be retrieved in app_ticket event, which is push from lark every hour. + * 4. We can trigger lark to push a new app_ticket + * 5. Therefore, after trigger resend app_ticket ticket, we have to + * pooling DB, as app_ticket will update ticket in DB + * see + * https://open.larksuite.com/document/ugTN1YjL4UTN24CO1UjN/uQjN1YjL0YTN24CN2UjN + * https://open.larksuite.com/document/ukTMukTMukTM/ukDNz4SO0MjL5QzM/auth-v3/auth/app_ticket_resend + */ + const appTicketNew = await makePoolingPromise(getAppTicketFromKeys); + if (appTicketNew) { + log.debug("has new app ticket", appTicketNew); + return appTicketNew; + } + log.error("app ticket not found"); + throw new Error("No app ticket found"); +}; + +export const getAppAccessToken: () => Promise = async () => { + log.debug("get app access token invoked"); + const appKeys = await getValidAppKeys(); + const appAccessToken = appKeys.app_access_token; + const expireDate = appKeys.expire_date; + + if (appAccessToken && expireDate && !isExpired(expireDate)) { + log.debug("get app access token not expired", appAccessToken); + return appAccessToken; + } + + const appTicket = await getAppTicket(); + + const fetchAppAccessToken = (app_ticket: string) => + fetch(`https://${LARK_HOST}/open-apis/auth/v3/app_access_token`, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + app_id: appKeys.app_id, + app_secret: appKeys.app_secret, + app_ticket, + }), + }); + + const resp = await fetchAppAccessToken(appTicket); + const data = await resp.json(); + + if (!resp.ok || data.code !== 0) { + logger.error("lark error with error: ", data, ", logid is:", resp.headers.get("X-Tt-Logid")); + // appticket invalid, mostly outdated, delete and renew one + if (data.code === 10012) { + await prisma.app.update({ + where: { slug: "lark-calendar" }, + data: { keys: { ...appKeys, app_ticket: "" } }, + }); + return getAppAccessToken(); + } + } + + const newAppAccessToken = data.app_access_token; + const newExpireDate = Math.round(Number(new Date()) / 1000 + data.expire); + + await prisma.app.update({ + where: { slug: "lark-calendar" }, + data: { + keys: { + ...appKeys, + app_access_token: newAppAccessToken, + expire_date: newExpireDate, + }, + }, + }); + + return newAppAccessToken; +}; diff --git a/packages/app-store/larkcalendar/lib/BotService.ts b/packages/app-store/larkcalendar/lib/BotService.ts new file mode 100644 index 0000000000..a09e217d94 --- /dev/null +++ b/packages/app-store/larkcalendar/lib/BotService.ts @@ -0,0 +1,130 @@ +import logger from "@calcom/lib/logger"; + +import { LARK_HOST } from "../common"; +import { getAppAccessToken } from "./AppAccessToken"; + +const log = logger.getChildLogger({ prefix: [`[[LarkTenantCredential]`] }); + +const msg = { + en_us: { + title: "Welcome to Cal.com!", + content: [ + [ + { + tag: "text", + text: "Cal.com is an open source scheduling infrastructure.", + }, + ], + [ + { + tag: "text", + text: 'It allows users to send a unique "cal.com" URL that allows anyone to create bookings on their calendars', + }, + ], + [ + { + tag: "text", + text: "", + }, + ], + [ + { + tag: "text", + text: "Get started", + }, + ], + [ + { + tag: "text", + text: "1. Visit https://cal.com and sign up for an account.", + }, + ], + [ + { + tag: "text", + text: '2. Then go to "Apps" in Cal -> install ', + }, + { + tag: "a", + text: '"Larksuite Calendar"', + href: "https://www.larksuite.com/hc/articles/057527702350", + }, + { + tag: "text", + text: " -> sign-in via Lark", + }, + ], + [ + { + tag: "text", + text: "3. Done. Create your Event Types and share your booking links with external participants!", + }, + ], + [ + { + tag: "text", + text: "", + }, + ], + [ + { + tag: "text", + text: "Do not hesitate to reach out to our agents if you need any assistance.", + }, + ], + [ + { + tag: "a", + text: "Get Help", + href: "https://applink.larksuite.com/client/helpdesk/open?id=6650327445582905610", + }, + ], + ], + }, +}; + +async function getTenantAccessTokenByTenantKey(tenantKey: string): Promise { + try { + const appAccessToken = await getAppAccessToken(); + const resp = await fetch(`https://${LARK_HOST}/open-apis/auth/v3/tenant_access_token`, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + app_access_token: appAccessToken, + tenant_key: tenantKey, + }), + }); + const data = await resp.json(); + return data.tenant_access_token; + } catch (error) { + log.error(error); + throw error; + } +} + +export async function sendPostMsg( + tenantKey: string, + senderOpenId: string, + message: string = JSON.stringify(msg) +): Promise<{ code: number; msg: string }> { + const tenantAccessToken = await getTenantAccessTokenByTenantKey(tenantKey); + + const response = await fetch(`https://${LARK_HOST}/open-apis/im/v1/messages?receive_id_type=open_id`, { + method: "POST", + headers: { + Authorization: "Bearer " + tenantAccessToken, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + receive_id: senderOpenId, + content: message, + msg_type: "post", + }), + }); + + const responseBody = await response.json(); + log.debug("send message success", responseBody); + return responseBody; +} diff --git a/packages/app-store/larkcalendar/lib/CalendarService.ts b/packages/app-store/larkcalendar/lib/CalendarService.ts new file mode 100644 index 0000000000..ee86978bff --- /dev/null +++ b/packages/app-store/larkcalendar/lib/CalendarService.ts @@ -0,0 +1,390 @@ +import { Credential } from "@prisma/client"; + +import { getLocation, getRichDescription } from "@calcom/lib/CalEventParser"; +import logger from "@calcom/lib/logger"; +import prisma from "@calcom/prisma"; +import type { BufferedBusyTime } from "@calcom/types/BufferedBusyTime"; +import type { + Calendar, + CalendarEvent, + EventBusyDate, + IntegrationCalendar, + NewCalendarEventType, +} from "@calcom/types/Calendar"; + +import { handleLarkError, isExpired, LARK_HOST } from "../common"; +import type { + CreateAttendeesResp, + CreateEventResp, + FreeBusyResp, + GetPrimaryCalendarsResp, + LarkAuthCredentials, + LarkEvent, + LarkEventAttendee, + ListCalendarsResp, + RefreshTokenResp, +} from "../types/LarkCalendar"; +import { getAppAccessToken } from "./AppAccessToken"; + +function parseEventTime2Timestamp(eventTime: string): string { + return String(+new Date(eventTime) / 1000); +} + +export default class LarkCalendarService implements Calendar { + private url = `https://${LARK_HOST}/open-apis`; + private integrationName = ""; + private log: typeof logger; + auth: { getToken: () => Promise }; + + constructor(credential: Credential) { + this.integrationName = "lark_calendar"; + this.auth = this.larkAuth(credential); + this.log = logger.getChildLogger({ prefix: [`[[lib] ${this.integrationName}`] }); + } + + private larkAuth = (credential: Credential) => { + const larkAuthCredentials = credential.key as LarkAuthCredentials; + return { + getToken: () => + !isExpired(larkAuthCredentials.expiry_date) + ? Promise.resolve(larkAuthCredentials.access_token) + : this.refreshAccessToken(credential), + }; + }; + + private refreshAccessToken = async (credential: Credential) => { + const larkAuthCredentials = credential.key as LarkAuthCredentials; + const refreshExpireDate = larkAuthCredentials.refresh_expires_date; + const refreshToken = larkAuthCredentials.refresh_token; + if (isExpired(refreshExpireDate) || !refreshToken) { + const res = await fetch("/api/integrations", { + method: "DELETE", + body: JSON.stringify({ id: credential.id }), + headers: { + "Content-Type": "application/json", + }, + }); + if (!res.ok) { + throw new Error("disconnection wrong"); + } + throw new Error("refresh token expires"); + } + try { + const appAccessToken = await getAppAccessToken(); + const resp = await this.fetcher(`/authen/v1/refresh_access_token`, { + method: "POST", + headers: { + Authorization: `Bearer ${appAccessToken}`, + "Content-Type": "application/json; charset=utf-8", + }, + body: JSON.stringify({ + grant_type: "refresh_token", + refresh_token: refreshToken, + }), + }); + + const data = await handleLarkError(resp, this.log); + const newLarkAuthCredentials: LarkAuthCredentials = { + ...larkAuthCredentials, + access_token: data.data.access_token, + expiry_date: Math.round(+new Date() / 1000 + data.data.expires_in), + }; + + await prisma.credential.update({ + where: { + id: credential.id, + }, + data: { + key: newLarkAuthCredentials, + }, + }); + + return newLarkAuthCredentials.access_token; + } catch (error) { + this.log.error("LarkCalendarService refreshAccessToken error", error); + throw error; + } + }; + + private fetcher = async (endpoint: string, init?: RequestInit | undefined) => { + const accessToken = await this.auth.getToken(); + return fetch(`${this.url}${endpoint}`, { + method: "GET", + headers: { + Authorization: "Bearer " + accessToken, + "Content-Type": "application/json", + ...init?.headers, + }, + ...init, + }); + }; + + async createEvent(event: CalendarEvent): Promise { + let eventId = ""; + let eventRespData; + const calendarId = event.destinationCalendar?.externalId; + if (!calendarId) { + throw new Error("no calendar id"); + } + try { + const eventResponse = await this.fetcher(`/calendar/v4/calendars/${calendarId}/events/create_event`, { + method: "POST", + body: JSON.stringify(this.translateEvent(event)), + }); + eventRespData = await handleLarkError(eventResponse, this.log); + eventId = eventRespData.data.event.event_id as string; + } catch (error) { + this.log.error(error); + throw error; + } + + try { + await this.createAttendees(event, eventId); + return { + ...eventRespData, + uid: eventRespData.data.event.event_id as string, + id: eventRespData.data.event.event_id as string, + type: "lark_calendar", + password: "", + url: "", + additionalInfo: {}, + }; + } catch (error) { + this.log.error(error); + await this.deleteEvent(eventId, event, calendarId); + throw error; + } + } + + private createAttendees = async (event: CalendarEvent, eventId: string) => { + const calendarId = event.destinationCalendar?.externalId; + if (!calendarId) { + this.log.error("no calendar id provided in createAttendees"); + throw new Error("no calendar id provided in createAttendees"); + } + const attendeeResponse = await this.fetcher( + `/calendar/v4/calendars/${calendarId}/events/${eventId}/attendees/create_attendees`, + { + method: "POST", + body: JSON.stringify({ + attendees: this.translateAttendees(event), + need_notification: false, + }), + } + ); + + return handleLarkError(attendeeResponse, this.log); + }; + + /** + * @param uid + * @param event + * @returns + */ + async updateEvent(uid: string, event: CalendarEvent, externalCalendarId?: string) { + const eventId = uid; + let eventRespData; + const calendarId = externalCalendarId || event.destinationCalendar?.externalId; + if (!calendarId) { + this.log.error("no calendar id provided in updateEvent"); + throw new Error("no calendar id provided in updateEvent"); + } + try { + const eventResponse = await this.fetcher( + `/calendar/v4/calendars/${calendarId}/events/${eventId}/patch_event`, + { + method: "PATCH", + body: JSON.stringify(this.translateEvent(event)), + } + ); + eventRespData = await handleLarkError(eventResponse, this.log); + } catch (error) { + this.log.error(error); + throw error; + } + + try { + // Since attendees cannot be changed any more, updateAttendees is not needed + // await this.updateAttendees(event, eventId); + return { + ...eventRespData, + uid: eventRespData.data.event.event_id as string, + id: eventRespData.data.event.event_id as string, + type: "lark_calendar", + password: "", + url: "", + additionalInfo: {}, + }; + } catch (error) { + this.log.error(error); + await this.deleteEvent(eventId, event); + throw error; + } + } + + /** + * @param uid + * @param event + * @returns + */ + async deleteEvent(uid: string, event: CalendarEvent, externalCalendarId?: string) { + const calendarId = externalCalendarId || event.destinationCalendar?.externalId; + if (!calendarId) { + this.log.error("no calendar id provided in deleteEvent"); + throw new Error("no calendar id provided in deleteEvent"); + } + try { + const response = await this.fetcher(`/calendar/v4/calendars/${calendarId}/events/${uid}`, { + method: "DELETE", + }); + await handleLarkError(response, this.log); + } catch (error) { + this.log.error(error); + throw error; + } + } + + async getAvailability( + dateFrom: string, + dateTo: string, + selectedCalendars: IntegrationCalendar[] + ): Promise { + const selectedCalendarIds = selectedCalendars + .filter((e) => e.integration === this.integrationName) + .map((e) => e.externalId) + .filter(Boolean); + if (selectedCalendarIds.length === 0 && selectedCalendars.length > 0) { + // Only calendars of other integrations selected + return Promise.resolve([]); + } + + try { + let queryIds = selectedCalendarIds; + if (queryIds.length === 0) { + queryIds = (await this.listCalendars()).map((e) => e.externalId).filter(Boolean) || []; + if (queryIds.length === 0) { + return Promise.resolve([]); + } + } + + const response = await this.fetcher(`/calendar/v4/freebusy/batch_get`, { + method: "POST", + headers: { + "x-tt-env": "boe_wangzichao", + }, + body: JSON.stringify({ + time_min: dateFrom, + time_max: dateTo, + calendar_ids: queryIds, + }), + }); + + const data = await handleLarkError(response, this.log); + + const busyData = + data.data.freebusy_list?.reduce((acc, cur) => { + acc.push({ + start: cur.start_time, + end: cur.end_time, + }); + return acc; + }, []) || []; + return busyData; + } catch (error) { + this.log.error(error); + return []; + } + } + + listCalendars = async (): Promise => { + try { + const resp = await this.fetcher(`/calendar/v4/calendars`); + const data = await handleLarkError(resp, this.log); + const result = data.data.calendar_list + .filter((cal) => { + if (cal.type !== "primary" && cal.type !== "shared") { + return false; + } + if (cal.permissions === "private") { + return false; + } + if (cal.role === "owner" || cal.role === "writer") { + return true; + } + return false; + }) + .map((cal) => { + const calendar: IntegrationCalendar = { + externalId: cal.calendar_id ?? "No Id", + integration: this.integrationName, + name: cal.summary_alias || cal.summary || "No calendar name", + primary: cal.type === "primary", + }; + return calendar; + }); + + if (result.some((cal) => !!cal.primary)) { + return result; + } + + // No primary calendar found, get primary calendar directly + const respPrimary = await this.fetcher(`/calendar/v4/calendars/primary`, { + method: "POST", + }); + const dataPrimary = await handleLarkError(respPrimary, this.log); + return dataPrimary.data.calendars.map((item) => { + const cal = item.calendar; + const calendar: IntegrationCalendar = { + externalId: cal.calendar_id ?? "No Id", + integration: this.integrationName, + name: cal.summary_alias || cal.summary || "No calendar name", + primary: cal.type === "primary", + }; + return calendar; + }); + } catch (err) { + this.log.error("There was an error contacting lark calendar service: ", err); + throw err; + } + }; + + private translateEvent = (event: CalendarEvent): LarkEvent => { + const larkEvent: LarkEvent = { + summary: event.title, + description: getRichDescription(event), + start_time: { + timestamp: parseEventTime2Timestamp(event.startTime), + timezone: event.organizer.timeZone, + }, + end_time: { + timestamp: parseEventTime2Timestamp(event.endTime), + timezone: event.organizer.timeZone, + }, + attendee_ability: "none", + free_busy_status: "busy", + reminders: [ + { + minutes: 5, + }, + ], + }; + if (event.location) { + larkEvent.location = { name: getLocation(event) }; + } + return larkEvent; + }; + + private translateAttendees = (event: CalendarEvent): LarkEventAttendee[] => { + const attendees: LarkEventAttendee[] = event.attendees + .filter((att) => att.email) + .map((att) => { + const attendee: LarkEventAttendee = { + type: "third_party", + is_optional: false, + third_party_email: att.email, + }; + return attendee; + }); + return attendees; + }; +} diff --git a/packages/app-store/larkcalendar/lib/index.ts b/packages/app-store/larkcalendar/lib/index.ts new file mode 100644 index 0000000000..e168c149df --- /dev/null +++ b/packages/app-store/larkcalendar/lib/index.ts @@ -0,0 +1 @@ +export { default as CalendarService } from "./CalendarService"; diff --git a/packages/app-store/larkcalendar/package.json b/packages/app-store/larkcalendar/package.json new file mode 100644 index 0000000000..290f1acf31 --- /dev/null +++ b/packages/app-store/larkcalendar/package.json @@ -0,0 +1,15 @@ +{ + "$schema": "https://json.schemastore.org/package.json", + "private": true, + "name": "@calcom/larkcalendar", + "version": "0.0.0", + "main": "./index.ts", + "description": "Lark Calendar is a time management and scheduling service developed by Lark. Allows users to create and edit events, with options available for type and time. Available to anyone that has a Lark account on both mobile and web versions.", + "dependencies": { + "@calcom/lib": "*", + "@calcom/prisma": "*" + }, + "devDependencies": { + "@calcom/types": "*" + } +} diff --git a/packages/app-store/larkcalendar/static/icon.svg b/packages/app-store/larkcalendar/static/icon.svg new file mode 100644 index 0000000000..0d1d2168e6 --- /dev/null +++ b/packages/app-store/larkcalendar/static/icon.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/packages/app-store/larkcalendar/types/LarkCalendar.ts b/packages/app-store/larkcalendar/types/LarkCalendar.ts new file mode 100644 index 0000000000..0274b6b2a8 --- /dev/null +++ b/packages/app-store/larkcalendar/types/LarkCalendar.ts @@ -0,0 +1,160 @@ +export type LarkAppKeys = { + app_id?: string; + app_secret?: string; + app_access_token?: string; + app_ticket?: string; + expire_date?: number; + open_verification_token?: string; +}; + +export type LarkAuthCredentials = { + expiry_date: number; + access_token: string; + refresh_token: string; + refresh_expires_date: number; +}; + +export type RefreshTokenResp = { + code: number; + msg: string; + data: { + access_token: string; + token_type: string; + expires_in: number; + name: string; + en_name: string; + avatar_url: string; + avatar_thumb: string; + avatar_middle: string; + avatar_big: string; + open_id: string; + union_id: string; + tenant_key: string; + refresh_expires_in: number; + refresh_token: string; + }; +}; + +export type LarkEvent = { + event_id?: string; + organizer_calendar_id?: string; + summary: string; + description: string; + start_time: { + timestamp: string; + timezone: string; + }; + end_time: { + timestamp: string; + timezone: string; + }; + attendee_ability: "none"; + free_busy_status: "busy"; + location?: { + name?: string; + }; + reminders: [ + { + minutes: number; + } + ]; +}; + +export type CreateEventResp = { + code: number; + msg: string; + data: { + event: LarkEvent; + }; +}; + +export type LarkEventAttendee = { + type: "user" | "third_party"; + is_optional: boolean; + user_id?: string; + third_party_email: string; +}; + +export type CreateAttendeesResp = { + code: number; + msg: string; + data: { + attendees: LarkEventAttendee[]; + }; +}; + +export type ListAttendeesResp = { + code: number; + msg: string; + data: { + items: (LarkEventAttendee & { attendee_id: string })[]; + has_more: boolean; + page_token: string; + }; +}; + +export type FreeBusyResp = { + code: number; + msg: string; + data: { + error_calendar_list: { + calendar_id: string; + error_msg: string; + }[]; + freebusy_list: { + calendar_id: string; + end_time: string; + start_time: string; + }[]; + }; +}; + +export type BufferedBusyTime = { + start: string; + end: string; +}; + +export type ListCalendarsResp = { + code: number; + msg: string; + data: { + has_more: boolean; + page_token: string; + sync_token: string; + calendar_list: [ + { + calendar_id: string; + summary: string; + description: string; + permissions: "private" | "show_only_free_busy" | "public"; + type: "unknown" | "shared" | "primary" | "google" | "resource" | "exchange"; + summary_alias: string; + is_deleted: boolean; + is_third_party: boolean; + role: "unknown" | "free_busy_reader" | "reader" | "writer" | "owner"; + } + ]; + }; +}; + +export type GetPrimaryCalendarsResp = { + code: number; + msg: string; + data: { + calendars: [ + { + calendar: { + calendar_id: string; + color: number; + description: string; + permissions: "private" | "show_only_free_busy" | "public"; + role: "unknown" | "free_busy_reader" | "reader" | "writer" | "owner"; + summary: string; + summary_alias: string; + type: "unknown" | "shared" | "primary" | "google" | "resource" | "exchange"; + }; + user_id: string; + } + ]; + }; +}; diff --git a/packages/prisma/seed-app-store.ts b/packages/prisma/seed-app-store.ts index 4dbc00e1f8..6aed568b3e 100644 --- a/packages/prisma/seed-app-store.ts +++ b/packages/prisma/seed-app-store.ts @@ -166,6 +166,17 @@ export default async function main() { client_secret: process.env.MS_GRAPH_CLIENT_SECRET, }); } + if ( + process.env.LARK_OPEN_APP_ID && + process.env.LARK_OPEN_APP_SECRET && + process.env.LARK_OPEN_VERIFICATION_TOKEN + ) { + await createApp("lark-calendar", "larkcalendar", ["calendar"], "lark_calendar", { + app_id: process.env.LARK_OPEN_APP_ID, + app_secret: process.env.LARK_OPEN_APP_SECRET, + open_verification_token: process.env.LARK_OPEN_VERIFICATION_TOKEN, + }); + } // Video apps if (process.env.DAILY_API_KEY) { await createApp("daily-video", "dailyvideo", ["video"], "daily_video", { diff --git a/yarn.lock b/yarn.lock index 11e90dedf9..75fe4075a9 100644 --- a/yarn.lock +++ b/yarn.lock @@ -208,7 +208,7 @@ jsesc "^2.5.1" source-map "^0.5.0" -"@babel/generator@^7.12.11", "@babel/generator@^7.12.5", "@babel/generator@^7.18.6", "@babel/generator@^7.18.7", "@babel/generator@^7.7.2": +"@babel/generator@^7.12.11", "@babel/generator@^7.12.5", "@babel/generator@^7.18.7", "@babel/generator@^7.7.2": version "7.18.7" resolved "https://registry.yarnpkg.com/@babel/generator/-/generator-7.18.7.tgz#2aa78da3c05aadfc82dbac16c99552fc802284bd" integrity sha512-shck+7VLlY72a2w9c3zYWuE1pwOKEiQHV7GTUbSnhyl5eu3i04t30tBY82ZRWrDfo3gkakCFtevExnxbkf2a3A== @@ -244,6 +244,24 @@ jsesc "^2.5.1" source-map "^0.5.0" +"@babel/generator@^7.18.6": + version "7.18.12" + resolved "https://registry.yarnpkg.com/@babel/generator/-/generator-7.18.12.tgz#fa58daa303757bd6f5e4bbca91b342040463d9f4" + integrity sha512-dfQ8ebCN98SvyL7IxNMCUtZQSq5R7kxgN+r8qYTGDmmSion1hX2C0zq2yo1bsCDhXixokv1SAWTZUMYbO/V5zg== + dependencies: + "@babel/types" "^7.18.10" + "@jridgewell/gen-mapping" "^0.3.2" + jsesc "^2.5.1" + +"@babel/generator@^7.18.9": + version "7.18.9" + resolved "https://registry.yarnpkg.com/@babel/generator/-/generator-7.18.9.tgz#68337e9ea8044d6ddc690fb29acae39359cca0a5" + integrity sha512-wt5Naw6lJrL1/SGkipMiFxJjtyczUWTP38deiP1PO60HsBjDeKk08CGC3S8iVuvf0FmTdgKwU1KIXzSKL1G0Ug== + dependencies: + "@babel/types" "^7.18.9" + "@jridgewell/gen-mapping" "^0.3.2" + jsesc "^2.5.1" + "@babel/helper-annotate-as-pure@^7.16.7": version "7.16.7" resolved "https://registry.yarnpkg.com/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.16.7.tgz#bb2339a7534a9c128e3102024c60760a3a7f3862" @@ -352,10 +370,10 @@ dependencies: "@babel/types" "^7.16.7" -"@babel/helper-environment-visitor@^7.18.6": - version "7.18.6" - resolved "https://registry.yarnpkg.com/@babel/helper-environment-visitor/-/helper-environment-visitor-7.18.6.tgz#b7eee2b5b9d70602e59d1a6cad7dd24de7ca6cd7" - integrity sha512-8n6gSfn2baOY+qlp+VSzsosjCVGFqWKmDF0cCWOybh52Dw3SEyoWR1KrhMJASjLwIEkkAufZ0xvr+SxLHSpy2Q== +"@babel/helper-environment-visitor@^7.18.6", "@babel/helper-environment-visitor@^7.18.9": + version "7.18.9" + resolved "https://registry.yarnpkg.com/@babel/helper-environment-visitor/-/helper-environment-visitor-7.18.9.tgz#0c0cee9b35d2ca190478756865bb3528422f51be" + integrity sha512-3r/aACDJ3fhQ/EVgFy0hpj8oHyHpQc+LPtJoY9SzTThAsStm4Ptegq92vqKoE3vD706ZVFWITnMnxucw+S9Ipg== "@babel/helper-explode-assignable-expression@^7.18.6": version "7.18.6" @@ -381,13 +399,13 @@ "@babel/template" "^7.16.7" "@babel/types" "^7.17.0" -"@babel/helper-function-name@^7.18.6": - version "7.18.6" - resolved "https://registry.yarnpkg.com/@babel/helper-function-name/-/helper-function-name-7.18.6.tgz#8334fecb0afba66e6d87a7e8c6bb7fed79926b83" - integrity sha512-0mWMxV1aC97dhjCah5U5Ua7668r5ZmSC2DLfH2EZnf9c3/dHZKiFa5pRLMH5tjSl471tY6496ZWk/kjNONBxhw== +"@babel/helper-function-name@^7.18.6", "@babel/helper-function-name@^7.18.9": + version "7.18.9" + resolved "https://registry.yarnpkg.com/@babel/helper-function-name/-/helper-function-name-7.18.9.tgz#940e6084a55dee867d33b4e487da2676365e86b0" + integrity sha512-fJgWlZt7nxGksJS9a0XdSaI4XvpExnNIgRP+rVefWh5U7BL8pPuir6SJUmFKRfjWQ51OtWSzwOxhaH/EBWWc0A== dependencies: "@babel/template" "^7.18.6" - "@babel/types" "^7.18.6" + "@babel/types" "^7.18.9" "@babel/helper-get-function-arity@^7.16.7": version "7.16.7" @@ -537,6 +555,11 @@ dependencies: "@babel/types" "^7.18.6" +"@babel/helper-string-parser@^7.18.10": + version "7.18.10" + resolved "https://registry.yarnpkg.com/@babel/helper-string-parser/-/helper-string-parser-7.18.10.tgz#181f22d28ebe1b3857fa575f5c290b1aaf659b56" + integrity sha512-XtIfWmeNY3i4t7t4D2t02q50HvqHybPqW2ki1kosnvWCwuCMeo81Jf0gwr85jy/neUdg5XDdeFE/80DXiO+njw== + "@babel/helper-validator-identifier@^7.12.11", "@babel/helper-validator-identifier@^7.16.7": version "7.16.7" resolved "https://registry.yarnpkg.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.16.7.tgz#e8c602438c4a8195751243da9031d1607d247cad" @@ -567,7 +590,7 @@ "@babel/traverse" "^7.18.6" "@babel/types" "^7.18.6" -"@babel/helpers@^7.12.5", "@babel/helpers@^7.18.6": +"@babel/helpers@^7.12.5": version "7.18.6" resolved "https://registry.yarnpkg.com/@babel/helpers/-/helpers-7.18.6.tgz#4c966140eaa1fcaa3d5a8c09d7db61077d4debfd" integrity sha512-vzSiiqbQOghPngUYt/zWGvK3LAsPhz55vc9XNN0xAl2gV4ieShI2OQli5duxWHD+72PZPTKAcfcZDE1Cwc5zsQ== @@ -594,6 +617,15 @@ "@babel/traverse" "^7.17.9" "@babel/types" "^7.17.0" +"@babel/helpers@^7.18.6": + version "7.18.9" + resolved "https://registry.yarnpkg.com/@babel/helpers/-/helpers-7.18.9.tgz#4bef3b893f253a1eced04516824ede94dcfe7ff9" + integrity sha512-Jf5a+rbrLoR4eNdUmnFu8cN5eNJT6qdTdOg5IHIzq87WwyRw9PwguLFOWYgktN/60IP4fgDUawJvs7PjQIzELQ== + dependencies: + "@babel/template" "^7.18.6" + "@babel/traverse" "^7.18.9" + "@babel/types" "^7.18.9" + "@babel/highlight@^7.16.7": version "7.16.10" resolved "https://registry.yarnpkg.com/@babel/highlight/-/highlight-7.16.10.tgz#744f2eb81579d6eea753c227b0f570ad785aba88" @@ -637,6 +669,11 @@ resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.17.9.tgz#9c94189a6062f0291418ca021077983058e171ef" integrity sha512-vqUSBLP8dQHFPdPi9bc5GK9vRkYHJ49fsZdtoJ8EQ8ibpwk5rPKfvNIwChB0KVXcIjcepEBBd2VHC5r9Gy8ueg== +"@babel/parser@^7.18.9": + version "7.18.9" + resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.18.9.tgz#f2dde0c682ccc264a9a8595efd030a5cc8fd2539" + integrity sha512-9uJveS9eY9DJ0t64YbIBZICtJy8a5QrDEVdiLCG97fVLpDTpGX7t8mMSb6OWw6Lrnjqj4O8zwjELX3dhoMgiBg== + "@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression@^7.18.6": version "7.18.6" resolved "https://registry.yarnpkg.com/@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression/-/plugin-bugfix-safari-id-destructuring-collision-in-function-expression-7.18.6.tgz#da5b8f9a580acdfbe53494dba45ea389fb09a4d2" @@ -1600,6 +1637,22 @@ debug "^4.1.0" globals "^11.1.0" +"@babel/traverse@^7.18.9": + version "7.18.9" + resolved "https://registry.yarnpkg.com/@babel/traverse/-/traverse-7.18.9.tgz#deeff3e8f1bad9786874cb2feda7a2d77a904f98" + integrity sha512-LcPAnujXGwBgv3/WHv01pHtb2tihcyW1XuL9wd7jqh1Z8AQkTd+QVjMrMijrln0T7ED3UXLIy36P9Ao7W75rYg== + dependencies: + "@babel/code-frame" "^7.18.6" + "@babel/generator" "^7.18.9" + "@babel/helper-environment-visitor" "^7.18.9" + "@babel/helper-function-name" "^7.18.9" + "@babel/helper-hoist-variables" "^7.18.6" + "@babel/helper-split-export-declaration" "^7.18.6" + "@babel/parser" "^7.18.9" + "@babel/types" "^7.18.9" + debug "^4.1.0" + globals "^11.1.0" + "@babel/types@7.13.0": version "7.13.0" resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.13.0.tgz#74424d2816f0171b4100f0ab34e9a374efdf7f80" @@ -1633,6 +1686,23 @@ "@babel/helper-validator-identifier" "^7.16.7" to-fast-properties "^2.0.0" +"@babel/types@^7.18.10": + version "7.18.10" + resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.18.10.tgz#4908e81b6b339ca7c6b7a555a5fc29446f26dde6" + integrity sha512-MJvnbEiiNkpjo+LknnmRrqbY1GPUUggjv+wQVjetM/AONoupqRALB7I6jGqNUAZsKcRIEu2J6FRFvsczljjsaQ== + dependencies: + "@babel/helper-string-parser" "^7.18.10" + "@babel/helper-validator-identifier" "^7.18.6" + to-fast-properties "^2.0.0" + +"@babel/types@^7.18.9": + version "7.18.9" + resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.18.9.tgz#7148d64ba133d8d73a41b3172ac4b83a1452205f" + integrity sha512-WwMLAg2MvJmt/rKEVQBBhIVffMmnilX4oe0sRe7iPOHIGsqpruFHHdrfj4O1CMMtgMtCU4oPafZjDPCRgO57Wg== + dependencies: + "@babel/helper-validator-identifier" "^7.18.6" + to-fast-properties "^2.0.0" + "@base2/pretty-print-object@1.0.1": version "1.0.1" resolved "https://registry.yarnpkg.com/@base2/pretty-print-object/-/pretty-print-object-1.0.1.tgz#371ba8be66d556812dc7fb169ebc3c08378f69d4" @@ -4391,9 +4461,9 @@ tslib "^1.9.3" "@sentry/cli@^1.73.0": - version "1.74.4" - resolved "https://registry.yarnpkg.com/@sentry/cli/-/cli-1.74.4.tgz#7df82f68045a155e1885bfcbb5d303e5259eb18e" - integrity sha512-BMfzYiedbModsNBJlKeBOLVYUtwSi99LJ8gxxE4Bp5N8hyjNIN0WVrozAVZ27mqzAuy6151Za3dpmOLO86YlGw== + version "1.74.5" + resolved "https://registry.yarnpkg.com/@sentry/cli/-/cli-1.74.5.tgz#4a5c622913087c9ab6f82994da9a7526423779b8" + integrity sha512-Ze1ec306ZWHtrxKypOJ8nhtFqkrx2f/6bRH+DcJzEQ3bBePQ0ZnqJTTe4BBHADYBtxFIaUWzCZ6DquLz2Zv/sw== dependencies: https-proxy-agent "^5.0.0" mkdirp "^0.5.5" @@ -5837,6 +5907,11 @@ dependencies: "@types/node" "*" +"@types/debounce@^1.2.1": + version "1.2.1" + resolved "https://registry.yarnpkg.com/@types/debounce/-/debounce-1.2.1.tgz#79b65710bc8b6d44094d286aecf38e44f9627852" + integrity sha512-epMsEE85fi4lfmJUH/89/iV/LI+F5CvNIvmgs5g5jYFPfhO2S/ae8WSsLOKWdwtoaZw9Q2IhJ4tQ5tFCcS/4HA== + "@types/debug@4.1.7", "@types/debug@^4.0.0": version "4.1.7" resolved "https://registry.yarnpkg.com/@types/debug/-/debug-4.1.7.tgz#7cc0ea761509124709b8b2d1090d8f6c17aadb82" @@ -17326,9 +17401,9 @@ next-transpile-modules@^9.0.0: escalade "^3.1.1" next-validations@^0.2.0: - version "0.2.0" - resolved "https://registry.yarnpkg.com/next-validations/-/next-validations-0.2.0.tgz#ce3c4bc332b115beda633521fd81e587987864eb" - integrity sha512-QMF2hRNSSbjeBaCYqpt3mEM9CkXXzaMCWCvPyi5/vKTBjbgkiYtaQnUfjj5eH8dX+ZmRrBYGgN1EKqL7ZnI0wQ== + version "0.2.1" + resolved "https://registry.yarnpkg.com/next-validations/-/next-validations-0.2.1.tgz#68010c9b017ba48eec4f404fd42eb9b0c7324737" + integrity sha512-92pR14MPTTx0ynlvYH2TwMf7WiGiznNL/l0dtZyKPw3x48rcMhwEZrP1ZmsMJwzp5D+U+sY2deexeLWC8rlNtQ== next@12.2.0: version "12.2.0"