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),
+});