Revert "feat(app-store): add feishu calendar (#13089)" (#13094)

This reverts commit a40520b90c.
This commit is contained in:
Peer Richelsen 2024-01-08 12:28:59 +00:00 committed by GitHub
parent a40520b90c
commit 69677495ca
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
25 changed files with 4636 additions and 1446 deletions

View File

@ -6,7 +6,6 @@ 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,7 +44,6 @@ 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,

View File

@ -22,7 +22,6 @@ 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,7 +101,6 @@ 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,

View File

@ -6,7 +6,6 @@ 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,7 +44,6 @@ 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,

View File

@ -22,7 +22,6 @@ 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"),

View File

@ -1,11 +0,0 @@
---
items:
- 1.png
- 2.png
- 3.png
- 4.png
---
<iframe class="w-full aspect-video" width="560" height="315" src="https://www.youtube.com/embed/ciqbZ466XSQ" title="YouTube video player" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture" allowfullscreen></iframe>
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.

View File

@ -1,21 +0,0 @@
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;

View File

@ -1,50 +0,0 @@
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) }),
});

View File

@ -1,130 +0,0 @@
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) }),
});

View File

@ -1,123 +0,0 @@
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) }),
});

View File

@ -1,3 +0,0 @@
export { default as add } from "./add";
export { default as callback } from "./callback";
export { default as events } from "./events";

View File

@ -1,26 +0,0 @@
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<FeishuAppKeys>;
export const isExpired = (expiryDate: number) =>
!expiryDate || expiryDate < Math.round(Number(new Date()) / 1000);
export async function handleFeishuError<T extends { code: number; msg: string }>(
response: Response,
log: typeof logger
): Promise<T> {
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;
}

View File

@ -1,3 +0,0 @@
export * as api from "./api";
export * as lib from "./lib";
export { metadata } from "./_metadata";

View File

@ -1,159 +0,0 @@
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<T>(
promiseCreator: () => Promise<T | null>,
times = 24,
delay = 5 * 1000
): Promise<T | null> {
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<ReturnType<typeof getAppKeys>> => {
const appKeys = await getAppKeys();
const validAppKeys = appKeysSchema.parse(appKeys);
return validAppKeys;
};
const getAppTicketFromKeys = async (): Promise<string> => {
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<string> => {
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<string> = 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;
};

View File

@ -1,130 +0,0 @@
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<string> {
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;
}

View File

@ -1,426 +0,0 @@
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<string> };
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<RefreshTokenResp>(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<NewCalendarEventType> {
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<CreateEventResp>(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<CreateAttendeesResp>(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<CreateEventResp>(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<EventBusyDate[]> {
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<FreeBusyResp>(response, this.log);
const busyData =
data.data.freebusy_list?.reduce<BufferedBusyTime[]>((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<IntegrationCalendar[]> => {
try {
const resp = await this.fetcher(`/calendar/v4/calendars`);
const data = await handleFeishuError<ListCalendarsResp>(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<GetPrimaryCalendarsResp>(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;
};
}

View File

@ -1 +0,0 @@
export { default as CalendarService } from "./CalendarService";

View File

@ -1,15 +0,0 @@
{
"$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": "*"
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 482 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 418 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 870 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 790 KiB

View File

@ -1,5 +0,0 @@
<svg width="131" height="105" viewBox="0 0 131 105" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M68.046 55.551L68.366 55.232C68.574 55.024 68.798 54.8 69.022 54.592L69.47 54.16L70.797 52.849L72.62 51.074L74.171 49.539L75.626 48.099L77.146 46.596L78.537 45.221L80.488 43.302C80.856 42.934 81.239 42.582 81.623 42.23C82.327 41.591 83.062 40.967 83.798 40.359C84.486 39.816 85.189 39.288 85.909 38.776C86.916 38.057 87.956 37.401 89.011 36.761C90.051 36.154 91.122 35.578 92.21 35.0342C93.233 34.5385 94.288 34.0748 95.36 33.659C95.952 33.4191 96.559 33.2112 97.167 33.0033C97.471 32.9074 97.775 32.7954 98.094 32.6995C95.392 22.0651 90.45 12.1504 83.59 3.5949C82.263 1.9478 80.248 0.988297 78.137 0.988297H22.0867C21.511 0.988297 21.0312 1.452 21.0312 2.0437C21.0312 2.3795 21.1912 2.6834 21.463 2.8913C40.5889 16.9158 56.452 34.9383 67.918 55.695L68.046 55.551Z" fill="#00D6B9"/>
<path d="M45.77 104.885C74.715 104.885 99.934 88.91 113.079 65.306C113.543 64.474 113.99 63.643 114.422 62.795C113.766 64.059 113.031 65.274 112.215 66.425C111.927 66.825 111.639 67.225 111.336 67.625C110.952 68.12 110.568 68.584 110.168 69.048C109.848 69.416 109.529 69.768 109.193 70.119C108.521 70.823 107.818 71.495 107.082 72.118C106.666 72.47 106.266 72.806 105.835 73.126C105.339 73.51 104.827 73.877 104.315 74.213C103.996 74.437 103.66 74.645 103.324 74.853C102.988 75.061 102.636 75.269 102.268 75.477C101.549 75.876 100.797 76.26 100.046 76.596C99.39 76.884 98.718 77.156 98.047 77.412C97.311 77.683 96.575 77.923 95.808 78.131C94.672 78.451 93.537 78.691 92.37 78.867C91.538 78.995 90.675 79.091 89.827 79.155C88.931 79.219 88.02 79.235 87.108 79.235C86.101 79.219 85.093 79.155 84.07 79.043C83.318 78.963 82.567 78.851 81.815 78.723C81.16 78.611 80.504 78.467 79.848 78.307C79.496 78.227 79.161 78.131 78.809 78.035C77.849 77.779 76.89 77.507 75.93 77.236C75.451 77.092 74.971 76.964 74.507 76.82C73.787 76.612 73.084 76.388 72.38 76.164C71.804 75.988 71.229 75.796 70.653 75.604C70.109 75.429 69.55 75.253 69.006 75.061L67.887 74.677C67.439 74.517 66.975 74.357 66.527 74.197L65.568 73.845C64.928 73.622 64.288 73.382 63.665 73.142C63.297 72.998 62.929 72.87 62.561 72.726C62.066 72.534 61.586 72.342 61.09 72.15C60.578 71.942 60.051 71.735 59.539 71.527L58.531 71.111L57.284 70.599L56.325 70.199L55.333 69.768L54.47 69.384L53.686 69.032L52.886 68.664L52.071 68.28L51.031 67.801L49.944 67.289C49.56 67.097 49.176 66.921 48.793 66.729L47.817 66.249C30.6102 57.662 15.0824 46.084 1.93729 32.0435C1.53749 31.6277 0.881889 31.5957 0.450089 31.9955C0.242188 32.1874 0.114288 32.4752 0.114288 32.7631L0.146187 82.225V86.239C0.146187 88.574 1.29759 90.749 3.23259 92.044C15.818 100.455 30.6262 104.917 45.77 104.885Z" fill="#3370FF"/>
<path d="M130.83 35.1629C121.059 30.3815 109.881 29.358 99.406 32.3004C98.958 32.4284 98.527 32.5563 98.095 32.6842C97.791 32.7802 97.487 32.8761 97.167 32.9881C96.56 33.196 95.952 33.4198 95.36 33.6437C94.289 34.0595 93.249 34.5233 92.21 35.019C91.123 35.5467 90.051 36.122 89.012 36.73C87.94 37.354 86.917 38.025 85.909 38.745C85.19 39.257 84.486 39.784 83.799 40.328C83.047 40.936 82.327 41.544 81.624 42.199C81.24 42.551 80.872 42.903 80.488 43.271L78.537 45.19L77.146 46.565L75.627 48.068L74.172 49.507L72.62 51.043L70.813 52.834L69.486 54.145L69.038 54.577C68.83 54.785 68.607 55.008 68.383 55.216L68.063 55.536L67.567 56C67.375 56.176 67.199 56.336 67.007 56.512C62.194 60.941 56.821 64.731 51.048 67.818L52.087 68.297L52.903 68.681L53.702 69.049L54.486 69.401L55.35 69.785L56.341 70.216L57.301 70.616L58.548 71.128L59.555 71.544C60.067 71.752 60.595 71.959 61.107 72.167C61.586 72.359 62.082 72.551 62.578 72.743C62.946 72.887 63.313 73.015 63.681 73.159C64.321 73.399 64.96 73.623 65.584 73.862L66.544 74.214C66.991 74.374 67.439 74.534 67.903 74.694L69.022 75.078C69.566 75.254 70.11 75.446 70.669 75.621C71.245 75.813 71.821 75.989 72.397 76.181C73.1 76.405 73.82 76.613 74.523 76.837C75.003 76.981 75.483 77.125 75.947 77.253C76.906 77.524 77.866 77.796 78.825 78.052C79.177 78.148 79.513 78.228 79.865 78.324C80.52 78.484 81.176 78.612 81.832 78.74C82.583 78.868 83.335 78.98 84.086 79.06C85.11 79.172 86.117 79.236 87.125 79.252C88.036 79.268 88.948 79.236 89.843 79.172C90.707 79.108 91.554 79.012 92.386 78.884C93.537 78.708 94.689 78.452 95.824 78.148C96.576 77.94 97.327 77.7 98.063 77.429C98.735 77.189 99.406 76.917 100.062 76.613C100.813 76.277 101.565 75.893 102.285 75.494C102.637 75.302 102.988 75.094 103.34 74.87C103.692 74.662 104.012 74.438 104.332 74.23C104.843 73.878 105.355 73.527 105.851 73.143C106.283 72.823 106.698 72.487 107.098 72.135C107.818 71.512 108.521 70.84 109.193 70.136C109.529 69.785 109.849 69.433 110.169 69.065C110.568 68.601 110.968 68.121 111.336 67.642C111.64 67.258 111.928 66.858 112.215 66.442C113.015 65.291 113.751 64.092 114.406 62.844L115.158 61.357L121.842 48.036L121.922 47.876C124.129 43.111 127.136 38.825 130.83 35.1629Z" fill="#133C9A"/>
</svg>

Before

Width:  |  Height:  |  Size: 4.8 KiB

View File

@ -1,160 +0,0 @@
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;
}
];
};
};

View File

@ -1,9 +0,0 @@
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),
});

4803
yarn.lock

File diff suppressed because it is too large Load Diff