diff --git a/packages/app-store/apps.keys-schemas.generated.ts b/packages/app-store/apps.keys-schemas.generated.ts index e2fdde3852..c9eb0a711e 100644 --- a/packages/app-store/apps.keys-schemas.generated.ts +++ b/packages/app-store/apps.keys-schemas.generated.ts @@ -6,6 +6,7 @@ import { appKeysSchema as alby_zod_ts } from "./alby/zod"; import { appKeysSchema as basecamp3_zod_ts } from "./basecamp3/zod"; import { appKeysSchema as dailyvideo_zod_ts } from "./dailyvideo/zod"; import { appKeysSchema as fathom_zod_ts } from "./fathom/zod"; +import { appKeysSchema as feishucalendar_zod_ts } from "./feishucalendar/zod"; import { appKeysSchema as ga4_zod_ts } from "./ga4/zod"; import { appKeysSchema as giphy_zod_ts } from "./giphy/zod"; import { appKeysSchema as googlecalendar_zod_ts } from "./googlecalendar/zod"; @@ -45,6 +46,7 @@ export const appKeysSchemas = { basecamp3: basecamp3_zod_ts, dailyvideo: dailyvideo_zod_ts, fathom: fathom_zod_ts, + feishucalendar: feishucalendar_zod_ts, ga4: ga4_zod_ts, giphy: giphy_zod_ts, googlecalendar: googlecalendar_zod_ts, diff --git a/packages/app-store/apps.metadata.generated.ts b/packages/app-store/apps.metadata.generated.ts index 5e74a228cb..e18667b7ef 100644 --- a/packages/app-store/apps.metadata.generated.ts +++ b/packages/app-store/apps.metadata.generated.ts @@ -22,6 +22,7 @@ import { metadata as exchange2016calendar__metadata_ts } from "./exchange2016cal import exchangecalendar_config_json from "./exchangecalendar/config.json"; import facetime_config_json from "./facetime/config.json"; import fathom_config_json from "./fathom/config.json"; +import { metadata as feishucalendar__metadata_ts } from "./feishucalendar/_metadata"; import ga4_config_json from "./ga4/config.json"; import { metadata as giphy__metadata_ts } from "./giphy/_metadata"; import { metadata as googlecalendar__metadata_ts } from "./googlecalendar/_metadata"; @@ -102,6 +103,7 @@ export const appStoreMetadata = { exchangecalendar: exchangecalendar_config_json, facetime: facetime_config_json, fathom: fathom_config_json, + feishucalendar: feishucalendar__metadata_ts, ga4: ga4_config_json, giphy: giphy__metadata_ts, googlecalendar: googlecalendar__metadata_ts, diff --git a/packages/app-store/apps.schemas.generated.ts b/packages/app-store/apps.schemas.generated.ts index 279ba0782c..bf3c6dd256 100644 --- a/packages/app-store/apps.schemas.generated.ts +++ b/packages/app-store/apps.schemas.generated.ts @@ -6,6 +6,7 @@ import { appDataSchema as alby_zod_ts } from "./alby/zod"; import { appDataSchema as basecamp3_zod_ts } from "./basecamp3/zod"; import { appDataSchema as dailyvideo_zod_ts } from "./dailyvideo/zod"; import { appDataSchema as fathom_zod_ts } from "./fathom/zod"; +import { appDataSchema as feishucalendar_zod_ts } from "./feishucalendar/zod"; import { appDataSchema as ga4_zod_ts } from "./ga4/zod"; import { appDataSchema as giphy_zod_ts } from "./giphy/zod"; import { appDataSchema as googlecalendar_zod_ts } from "./googlecalendar/zod"; @@ -45,6 +46,7 @@ export const appDataSchemas = { basecamp3: basecamp3_zod_ts, dailyvideo: dailyvideo_zod_ts, fathom: fathom_zod_ts, + feishucalendar: feishucalendar_zod_ts, ga4: ga4_zod_ts, giphy: giphy_zod_ts, googlecalendar: googlecalendar_zod_ts, diff --git a/packages/app-store/apps.server.generated.ts b/packages/app-store/apps.server.generated.ts index 5c92f6400b..8c8e5170f5 100644 --- a/packages/app-store/apps.server.generated.ts +++ b/packages/app-store/apps.server.generated.ts @@ -22,6 +22,7 @@ export const apiHandlers = { exchangecalendar: import("./exchangecalendar/api"), facetime: import("./facetime/api"), fathom: import("./fathom/api"), + feishucalendar: import("./feishucalendar/api"), ga4: import("./ga4/api"), giphy: import("./giphy/api"), googlecalendar: import("./googlecalendar/api"), diff --git a/packages/app-store/feishucalendar/DESCRIPTION.md b/packages/app-store/feishucalendar/DESCRIPTION.md new file mode 100644 index 0000000000..3d8eaf9299 --- /dev/null +++ b/packages/app-store/feishucalendar/DESCRIPTION.md @@ -0,0 +1,11 @@ +--- +items: + - 1.png + - 2.png + - 3.png + - 4.png +--- + + + +Feishu Calendar is a time management and scheduling service developed by Feishu. Allows users to create and edit events, with options available for type and time. Available to anyone that has a Feishu account on both mobile and web versions. diff --git a/packages/app-store/feishucalendar/_metadata.ts b/packages/app-store/feishucalendar/_metadata.ts new file mode 100644 index 0000000000..4e2e8538a9 --- /dev/null +++ b/packages/app-store/feishucalendar/_metadata.ts @@ -0,0 +1,21 @@ +import type { AppMeta } from "@calcom/types/App"; + +import _package from "./package.json"; + +export const metadata = { + name: "Feishu Calendar", + description: _package.description, + installed: true, + type: "feishu_calendar", + title: "Feishu Calendar", + variant: "calendar", + categories: ["calendar"], + logo: "icon.svg", + publisher: "Feishu", + slug: "feishu-calendar", + url: "https://feishu.cn/", + email: "alan@larksuite.com", + dirName: "feishucalendar", +} as AppMeta; + +export default metadata; diff --git a/packages/app-store/feishucalendar/api/add.ts b/packages/app-store/feishucalendar/api/add.ts new file mode 100644 index 0000000000..ade235af50 --- /dev/null +++ b/packages/app-store/feishucalendar/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 getAppKeysFromSlug from "../../_utils/getAppKeysFromSlug"; +import { encodeOAuthState } from "../../_utils/oauth/encodeOAuthState"; +import { FEISHU_HOST } from "../common"; + +const feishuKeysSchema = z.object({ + app_id: z.string(), + app_secret: z.string(), +}); + +async function getHandler(req: NextApiRequest) { + const appKeys = await getAppKeysFromSlug("feishu-calendar"); + const { app_secret, app_id } = feishuKeysSchema.parse(appKeys); + + const state = encodeOAuthState(req); + + const params = { + app_id, + redirect_uri: `${WEBAPP_URL}/api/integrations/feishucalendar/callback`, + state, + }; + + const query = stringify(params); + + const url = `https://${FEISHU_HOST}/open-apis/authen/v1/index?${query}`; + + // trigger app_ticket_immediately + fetch(`https://${FEISHU_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/feishucalendar/api/callback.ts b/packages/app-store/feishucalendar/api/callback.ts new file mode 100644 index 0000000000..f17acbc8c2 --- /dev/null +++ b/packages/app-store/feishucalendar/api/callback.ts @@ -0,0 +1,130 @@ +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 getInstalledAppPath from "../../_utils/getInstalledAppPath"; +import { decodeOAuthState } from "../../_utils/oauth/decodeOAuthState"; +import { FEISHU_HOST } from "../common"; +import { getAppAccessToken } from "../lib/AppAccessToken"; +import type { FeishuAuthCredentials } from "../types/FeishuCalendar"; + +const log = logger.getSubLogger({ prefix: [`[[feishu/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://${FEISHU_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: FeishuAuthCredentials = { + 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 feishu 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: "feishu_calendar", + }, + }); + + if (!currentCredential) { + await prisma.credential.create({ + data: { + type: "feishu_calendar", + key, + userId: req.session?.user.id, + appId: "feishu-calendar", + }, + }); + } else { + await prisma.credential.update({ + data: { + type: "feishu_calendar", + key, + userId: req.session?.user.id, + appId: "feishu-calendar", + }, + where: { + id: currentCredential.id, + }, + }); + } + + const primaryCalendarResponse = await fetch( + `https://${FEISHU_HOST}/open-apis/calendar/v4/calendars/primary`, + { + method: "GET", + headers: { + Authorization: `Bearer ${key.access_token}`, + "Content-Type": "application/json", + }, + } + ); + + if (primaryCalendarResponse.status === 200) { + const primaryCalendar = await primaryCalendarResponse.json(); + + if (primaryCalendar.data.calendars.calendar.calendar_id && req.session?.user?.id) { + await prisma.selectedCalendar.create({ + data: { + userId: req.session?.user.id, + integration: "feishu_calendar", + externalId: primaryCalendar.data.calendars.calendar.calendar_id as string, + credentialId: currentCredential?.id, + }, + }); + } + } + + res.redirect( + getSafeRedirectUrl(state?.returnTo) ?? + getInstalledAppPath({ variant: "calendar", slug: "feishu-calendar" }) + ); + } 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/feishucalendar/api/events.ts b/packages/app-store/feishucalendar/api/events.ts new file mode 100644 index 0000000000..14a7377524 --- /dev/null +++ b/packages/app-store/feishucalendar/api/events.ts @@ -0,0 +1,123 @@ +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.getSubLogger({ prefix: [`[feishu/api/events]`] }); + +const feishuKeysSchema = 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 } = feishuKeysSchema.parse(appKeys); + + // used for events handler binding in feishu open platform, see + // https://open.larksuite.com/document/ukTMukTMukTM/uUTNz4SN1MjL1UzM?lang=en-US + if (req.body.type === "url_verification" && req.body.token === open_verification_token) { + log.debug("update token", req.body); + 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: "feishu-calendar" }, + data: { + keys: { + ...appKeys, + app_ticket: appTicket, + }, + }, + }); + return res.status(200).json({ code: 0, msg: "success" }); + } + + // used for handle user at bot in feishu 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/feishucalendar/api/index.ts b/packages/app-store/feishucalendar/api/index.ts new file mode 100644 index 0000000000..8745e9ffa4 --- /dev/null +++ b/packages/app-store/feishucalendar/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/feishucalendar/common.ts b/packages/app-store/feishucalendar/common.ts new file mode 100644 index 0000000000..1bbcebe760 --- /dev/null +++ b/packages/app-store/feishucalendar/common.ts @@ -0,0 +1,26 @@ +import type logger from "@calcom/lib/logger"; + +import getAppKeysFromSlug from "../_utils/getAppKeysFromSlug"; +import type { FeishuAppKeys } from "./types/FeishuCalendar"; + +export const FEISHU_HOST = "open.feishu.cn"; + +export const getAppKeys = () => getAppKeysFromSlug("feishu-calendar") as Promise; + +export const isExpired = (expiryDate: number) => + !expiryDate || expiryDate < Math.round(Number(new Date()) / 1000); + +export async function handleFeishuError( + response: Response, + log: typeof logger +): Promise { + const data: T = await response.json(); + if (!response.ok || data.code !== 0) { + log.error("feishu error with error: ", data, ", logid is:", response.headers.get("X-Tt-Logid")); + log.debug("feishu request with data", data); + throw data; + } + log.info("feishu request with logid:", response.headers.get("X-Tt-Logid")); + log.debug("feishu request with data", data); + return data; +} diff --git a/packages/app-store/feishucalendar/index.ts b/packages/app-store/feishucalendar/index.ts new file mode 100644 index 0000000000..5373eb04ef --- /dev/null +++ b/packages/app-store/feishucalendar/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/feishucalendar/lib/AppAccessToken.ts b/packages/app-store/feishucalendar/lib/AppAccessToken.ts new file mode 100644 index 0000000000..13f3959849 --- /dev/null +++ b/packages/app-store/feishucalendar/lib/AppAccessToken.ts @@ -0,0 +1,159 @@ +import { z } from "zod"; + +import logger from "@calcom/lib/logger"; +import prisma from "@calcom/prisma"; + +import { getAppKeys, isExpired, FEISHU_HOST } from "../common"; + +const log = logger.getSubLogger({ prefix: [`[[FeishuAppCredential]`] }); + +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), + app_access_token: z.string().optional(), + app_ticket: z.string().optional(), + expire_date: z.number().optional(), + open_verification_token: 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("feishu 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://${FEISHU_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 feishu every hour. + * 4. We can trigger feishu 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"); + return appAccessToken; + } + + const appTicket = await getAppTicket(); + + const fetchAppAccessToken = (app_ticket: string) => + fetch(`https://${FEISHU_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("feishu 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: "feishu-calendar" }, + data: { keys: { ...appKeys, app_ticket: "" } }, + }); + throw new Error("app_ticket invalid, please try again"); + } + } + + const newAppAccessToken = data.app_access_token; + const newExpireDate = Math.round(Number(new Date()) / 1000 + data.expire); + + await prisma.app.update({ + where: { slug: "feishu-calendar" }, + data: { + keys: { + ...appKeys, + app_access_token: newAppAccessToken, + expire_date: newExpireDate, + }, + }, + }); + + return newAppAccessToken; +}; diff --git a/packages/app-store/feishucalendar/lib/BotService.ts b/packages/app-store/feishucalendar/lib/BotService.ts new file mode 100644 index 0000000000..c3d7913e59 --- /dev/null +++ b/packages/app-store/feishucalendar/lib/BotService.ts @@ -0,0 +1,130 @@ +import logger from "@calcom/lib/logger"; + +import { FEISHU_HOST } from "../common"; +import { getAppAccessToken } from "./AppAccessToken"; + +const log = logger.getSubLogger({ prefix: [`[[FeishuTenantCredential]`] }); + +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: '"Feishu Calendar"', + href: "https://www.larksuite.com/hc/articles/057527702350", + }, + { + tag: "text", + text: " -> sign-in via Feishu", + }, + ], + [ + { + 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://${FEISHU_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://${FEISHU_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/feishucalendar/lib/CalendarService.ts b/packages/app-store/feishucalendar/lib/CalendarService.ts new file mode 100644 index 0000000000..3b3aeaa8ea --- /dev/null +++ b/packages/app-store/feishucalendar/lib/CalendarService.ts @@ -0,0 +1,426 @@ +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 type { CredentialPayload } from "@calcom/types/Credential"; + +import refreshOAuthTokens from "../../_utils/oauth/refreshOAuthTokens"; +import { handleFeishuError, isExpired, FEISHU_HOST } from "../common"; +import type { + CreateAttendeesResp, + CreateEventResp, + FreeBusyResp, + GetPrimaryCalendarsResp, + FeishuAuthCredentials, + FeishuEvent, + FeishuEventAttendee, + ListCalendarsResp, + RefreshTokenResp, +} from "../types/FeishuCalendar"; +import { getAppAccessToken } from "./AppAccessToken"; + +function parseEventTime2Timestamp(eventTime: string): string { + return String(+new Date(eventTime) / 1000); +} + +export default class FeishuCalendarService implements Calendar { + private url = `https://${FEISHU_HOST}/open-apis`; + private integrationName = ""; + private log: typeof logger; + auth: { getToken: () => Promise }; + private credential: CredentialPayload; + + constructor(credential: CredentialPayload) { + this.integrationName = "feishu_calendar"; + this.auth = this.feishuAuth(credential); + this.log = logger.getSubLogger({ prefix: [`[[lib] ${this.integrationName}`] }); + this.credential = credential; + } + + private feishuAuth = (credential: CredentialPayload) => { + const feishuAuthCredentials = credential.key as FeishuAuthCredentials; + return { + getToken: () => + !isExpired(feishuAuthCredentials.expiry_date) + ? Promise.resolve(feishuAuthCredentials.access_token) + : this.refreshAccessToken(credential), + }; + }; + + private refreshAccessToken = async (credential: CredentialPayload) => { + const feishuAuthCredentials = credential.key as FeishuAuthCredentials; + const refreshExpireDate = feishuAuthCredentials.refresh_expires_date; + const refreshToken = feishuAuthCredentials.refresh_token; + if (isExpired(refreshExpireDate) || !refreshToken) { + await prisma.credential.delete({ where: { id: credential.id } }); + throw new Error("Feishu Calendar refresh token expired"); + } + try { + const appAccessToken = await getAppAccessToken(); + const resp = await refreshOAuthTokens( + async () => + await fetch(`${this.url}/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, + }), + }), + "feishu-calendar", + credential.userId + ); + + const data = await handleFeishuError(resp, this.log); + this.log.debug( + "FeishuCalendarService refreshAccessToken data refresh_expires_in", + data.data.refresh_expires_in, + "and access token expire in", + data.data.expires_in + ); + const newFeishuAuthCredentials: FeishuAuthCredentials = { + refresh_token: data.data.refresh_token, + refresh_expires_date: Math.round(+new Date() / 1000 + data.data.refresh_expires_in), + 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: newFeishuAuthCredentials, + }, + }); + + return newFeishuAuthCredentials.access_token; + } catch (error) { + this.log.error("FeishuCalendarService refreshAccessToken error", error); + throw error; + } + }; + + private fetcher = async (endpoint: string, init?: RequestInit | undefined) => { + let accessToken = ""; + try { + accessToken = await this.auth.getToken(); + } catch (error) { + throw new Error("get access token error"); + } + + return fetch(`${this.url}${endpoint}`, { + method: "GET", + headers: { + Authorization: `Bearer ${accessToken}`, + "Content-Type": "application/json", + ...init?.headers, + }, + ...init, + }); + }; + + async createEvent(event: CalendarEvent, credentialId: number): Promise { + let eventId = ""; + let eventRespData; + const mainHostDestinationCalendar = event.destinationCalendar + ? event.destinationCalendar.find((cal) => cal.credentialId === this.credential.id) ?? + event.destinationCalendar[0] + : undefined; + const calendarId = mainHostDestinationCalendar?.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 handleFeishuError(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, credentialId); + return { + ...eventRespData, + uid: eventRespData.data.event.event_id as string, + id: eventRespData.data.event.event_id as string, + type: "feishu_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, credentialId: number) => { + const mainHostDestinationCalendar = event.destinationCalendar + ? event.destinationCalendar.find((cal) => cal.credentialId === credentialId) ?? + event.destinationCalendar[0] + : undefined; + const calendarId = mainHostDestinationCalendar?.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 handleFeishuError(attendeeResponse, this.log); + }; + + /** + * @param uid + * @param event + * @returns + */ + async updateEvent(uid: string, event: CalendarEvent, externalCalendarId?: string) { + const eventId = uid; + let eventRespData; + const mainHostDestinationCalendar = event.destinationCalendar?.find( + (cal) => cal.externalId === externalCalendarId + ); + const calendarId = externalCalendarId || mainHostDestinationCalendar?.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 handleFeishuError(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: "feishu_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 mainHostDestinationCalendar = event.destinationCalendar?.find( + (cal) => cal.externalId === externalCalendarId + ); + const calendarId = externalCalendarId || mainHostDestinationCalendar?.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 handleFeishuError(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", + body: JSON.stringify({ + time_min: dateFrom, + time_max: dateTo, + calendar_ids: queryIds, + }), + }); + + const data = await handleFeishuError(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 handleFeishuError(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", + email: cal.calendar_id ?? "", + }; + 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 handleFeishuError(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", + email: cal.calendar_id ?? "", + }; + return calendar; + }); + } catch (err) { + this.log.error("There was an error contacting feishu calendar service: ", err); + throw err; + } + }; + + private translateEvent = (event: CalendarEvent): FeishuEvent => { + const feishuEvent: FeishuEvent = { + 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) { + feishuEvent.location = { name: getLocation(event) }; + } + return feishuEvent; + }; + + private translateAttendees = (event: CalendarEvent): FeishuEventAttendee[] => { + const attendeeArray: FeishuEventAttendee[] = []; + event.attendees + .filter((att) => att.email) + .forEach((att) => { + const attendee: FeishuEventAttendee = { + type: "third_party", + is_optional: false, + third_party_email: att.email, + }; + attendeeArray.push(attendee); + }); + event.team?.members.forEach((member) => { + if (member.email !== this.credential.user?.email) { + const attendee: FeishuEventAttendee = { + type: "third_party", + is_optional: false, + third_party_email: member.email, + }; + attendeeArray.push(attendee); + } + }); + + return attendeeArray; + }; +} diff --git a/packages/app-store/feishucalendar/lib/index.ts b/packages/app-store/feishucalendar/lib/index.ts new file mode 100644 index 0000000000..e168c149df --- /dev/null +++ b/packages/app-store/feishucalendar/lib/index.ts @@ -0,0 +1 @@ +export { default as CalendarService } from "./CalendarService"; diff --git a/packages/app-store/feishucalendar/package.json b/packages/app-store/feishucalendar/package.json new file mode 100644 index 0000000000..836d62caa7 --- /dev/null +++ b/packages/app-store/feishucalendar/package.json @@ -0,0 +1,15 @@ +{ + "$schema": "https://json.schemastore.org/package.json", + "private": true, + "name": "@calcom/feishucalendar", + "version": "0.0.0", + "main": "./index.ts", + "description": "Feishu Calendar is a time management and scheduling service developed by Feishu. Allows users to create and edit events, with options available for type and time. Available to anyone that has a Feishu account on both mobile and web versions.", + "dependencies": { + "@calcom/lib": "*", + "@calcom/prisma": "*" + }, + "devDependencies": { + "@calcom/types": "*" + } +} diff --git a/packages/app-store/feishucalendar/static/1.png b/packages/app-store/feishucalendar/static/1.png new file mode 100644 index 0000000000..0cf2bf639d Binary files /dev/null and b/packages/app-store/feishucalendar/static/1.png differ diff --git a/packages/app-store/feishucalendar/static/2.png b/packages/app-store/feishucalendar/static/2.png new file mode 100644 index 0000000000..930b020a56 Binary files /dev/null and b/packages/app-store/feishucalendar/static/2.png differ diff --git a/packages/app-store/feishucalendar/static/3.png b/packages/app-store/feishucalendar/static/3.png new file mode 100644 index 0000000000..954a07cc6d Binary files /dev/null and b/packages/app-store/feishucalendar/static/3.png differ diff --git a/packages/app-store/feishucalendar/static/4.png b/packages/app-store/feishucalendar/static/4.png new file mode 100644 index 0000000000..a3d7c9c6da Binary files /dev/null and b/packages/app-store/feishucalendar/static/4.png differ diff --git a/packages/app-store/feishucalendar/static/icon.svg b/packages/app-store/feishucalendar/static/icon.svg new file mode 100644 index 0000000000..0d1d2168e6 --- /dev/null +++ b/packages/app-store/feishucalendar/static/icon.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/packages/app-store/feishucalendar/types/FeishuCalendar.ts b/packages/app-store/feishucalendar/types/FeishuCalendar.ts new file mode 100644 index 0000000000..ff39be4e66 --- /dev/null +++ b/packages/app-store/feishucalendar/types/FeishuCalendar.ts @@ -0,0 +1,160 @@ +export type FeishuAppKeys = { + app_id?: string; + app_secret?: string; + app_access_token?: string; + app_ticket?: string; + expire_date?: number; + open_verification_token?: string; +}; + +export type FeishuAuthCredentials = { + 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 FeishuEvent = { + 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: FeishuEvent; + }; +}; + +export type FeishuEventAttendee = { + type: "user" | "third_party"; + is_optional: boolean; + user_id?: string; + third_party_email: string; +}; + +export type CreateAttendeesResp = { + code: number; + msg: string; + data: { + attendees: FeishuEventAttendee[]; + }; +}; + +export type ListAttendeesResp = { + code: number; + msg: string; + data: { + items: (FeishuEventAttendee & { 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/app-store/feishucalendar/zod.ts b/packages/app-store/feishucalendar/zod.ts new file mode 100644 index 0000000000..8be7bb3254 --- /dev/null +++ b/packages/app-store/feishucalendar/zod.ts @@ -0,0 +1,9 @@ +import { z } from "zod"; + +export const appDataSchema = z.object({}); + +export const appKeysSchema = z.object({ + app_id: z.string().min(1), + app_secret: z.string().min(1), + open_verfication_token: z.string().min(1), +});