305 lines
11 KiB
TypeScript
305 lines
11 KiB
TypeScript
import { z } from "zod";
|
|
|
|
import dayjs from "@calcom/dayjs";
|
|
import prisma from "@calcom/prisma";
|
|
import type { Credential } from "@calcom/prisma/client";
|
|
import type { CalendarEvent } from "@calcom/types/Calendar";
|
|
import type { CredentialPayload } from "@calcom/types/Credential";
|
|
import type { PartialReference } from "@calcom/types/EventManager";
|
|
import type { VideoApiAdapter, VideoCallData } from "@calcom/types/VideoApiAdapter";
|
|
|
|
import { getWebexAppKeys } from "./getWebexAppKeys";
|
|
|
|
/** @link https://developer.webex.com/docs/meetings **/
|
|
const webexEventResultSchema = z.object({
|
|
id: z.string(),
|
|
webLink: z.string(),
|
|
siteUrl: z.string(),
|
|
password: z.string().optional().default(""),
|
|
});
|
|
|
|
export type WebexEventResult = z.infer<typeof webexEventResultSchema>;
|
|
|
|
/** @link https://developer.webex.com/docs/api/v1/meetings/create-a-meeting */
|
|
export const webexMeetingSchema = z.object({
|
|
start: z.date(),
|
|
end: z.date(),
|
|
});
|
|
|
|
/** @link https://developer.webex.com/docs/api/v1/meetings/list-meetings */
|
|
export const webexMeetingsSchema = z.object({
|
|
items: z.array(webexMeetingSchema),
|
|
});
|
|
|
|
/** @link https://developer.webex.com/docs/integrations#getting-an-access-token */
|
|
const webexTokenSchema = z.object({
|
|
scope: z.literal("spark:kms meeting:schedules_read meeting:schedules_write"),
|
|
token_type: z.literal("Bearer"),
|
|
access_token: z.string(),
|
|
expires_in: z.number().optional(),
|
|
refresh_token: z.string(),
|
|
refresh_token_expires_in: z.number(),
|
|
expiry_date: z.number(),
|
|
});
|
|
type WebexToken = z.infer<typeof webexTokenSchema>;
|
|
const isTokenValid = (token: WebexToken) => token.expiry_date < Date.now();
|
|
|
|
/** @link https://developer.webex.com/docs/integrations#using-the-refresh-token */
|
|
const webexRefreshedTokenSchema = z.object({
|
|
scope: z.literal("spark:kms meeting:schedules_read meeting:schedules_write"),
|
|
token_type: z.literal("Bearer"),
|
|
access_token: z.string(),
|
|
expires_in: z.number().optional(),
|
|
refresh_token: z.string(),
|
|
refresh_token_expires_in: z.number(),
|
|
});
|
|
|
|
const webexAuth = (credential: CredentialPayload) => {
|
|
const refreshAccessToken = async (refreshToken: string) => {
|
|
const { client_id, client_secret } = await getWebexAppKeys();
|
|
|
|
const response = await fetch("https://webexapis.com/v1/access_token", {
|
|
method: "POST",
|
|
headers: {
|
|
"Content-type": "application/x-www-form-urlencoded",
|
|
},
|
|
body: new URLSearchParams({
|
|
grant_type: "refresh_token",
|
|
client_id: client_id,
|
|
client_secret: client_secret,
|
|
refresh_token: refreshToken,
|
|
}),
|
|
});
|
|
|
|
const responseBody = await handleWebexResponse(response, credential.id);
|
|
|
|
if (responseBody.error) {
|
|
if (responseBody.error === "invalid_grant") {
|
|
return Promise.reject(new Error("Invalid grant for Cal.com webex app"));
|
|
}
|
|
}
|
|
// We check the if the new credentials matches the expected response structure
|
|
const parsedToken = webexRefreshedTokenSchema.safeParse(responseBody);
|
|
if (!parsedToken.success) {
|
|
return Promise.reject(new Error("Invalid refreshed tokens were returned"));
|
|
}
|
|
const newTokens = parsedToken.data;
|
|
const oldCredential = await prisma.credential.findUniqueOrThrow({ where: { id: credential.id } });
|
|
const parsedKey = webexTokenSchema.safeParse(oldCredential.key);
|
|
if (!parsedKey.success) {
|
|
return Promise.reject(new Error("Invalid credentials were saved in the DB"));
|
|
}
|
|
|
|
const key = parsedKey.data;
|
|
key.access_token = newTokens.access_token;
|
|
key.refresh_token = newTokens.refresh_token;
|
|
// set expiry date as offset from current time.
|
|
if (newTokens.expires_in) {
|
|
key.expiry_date = Math.round(Date.now() + newTokens.expires_in * 1000);
|
|
}
|
|
// Store new tokens in database.
|
|
await prisma.credential.update({ where: { id: credential.id }, data: { key } });
|
|
return newTokens.access_token;
|
|
};
|
|
return {
|
|
getToken: async () => {
|
|
let credentialKey: WebexToken | null = null;
|
|
try {
|
|
credentialKey = webexTokenSchema.parse(credential.key);
|
|
} catch (error) {
|
|
return Promise.reject("Webex credential keys parsing error");
|
|
}
|
|
|
|
return !isTokenValid(credentialKey)
|
|
? Promise.resolve(credentialKey.access_token)
|
|
: refreshAccessToken(credentialKey.refresh_token);
|
|
},
|
|
};
|
|
};
|
|
|
|
const WebexVideoApiAdapter = (credential: CredentialPayload): VideoApiAdapter => {
|
|
//TODO implement translateEvent for recurring events
|
|
const translateEvent = (event: CalendarEvent) => {
|
|
//To convert the Cal's CalendarEvent type to a webex meeting type
|
|
/** @link https://developer.webex.com/docs/api/v1/meetings/create-a-meeting */
|
|
//Required params - title, start, end
|
|
|
|
return {
|
|
title: event.title,
|
|
start: dayjs(event.startTime).utc().format(),
|
|
end: dayjs(event.endTime).utc().format(),
|
|
recurrence: event.recurrence, //Follows RFC 2445 https://www.ietf.org/rfc/rfc2445.txt, TODO check if needs conversion
|
|
// timezone: event.organizer.timeZone, // Comment this out for now
|
|
agenda: event.description,
|
|
enableJoinBeforeHost: true, //this is true in zoom's api, do we need it here?
|
|
invitees: event.attendees.map((attendee) => ({
|
|
email: attendee.email,
|
|
})),
|
|
sendEmail: true,
|
|
};
|
|
};
|
|
|
|
const fetchWebexApi = async (endpoint: string, options?: RequestInit) => {
|
|
const auth = webexAuth(credential);
|
|
const accessToken = await auth.getToken();
|
|
console.log("result of accessToken in fetchWebexApi", accessToken);
|
|
console.log("createMeeting options in fetchWebexApi", options);
|
|
const response = await fetch(`https://webexapis.com/v1/${endpoint}`, {
|
|
method: "GET",
|
|
...options,
|
|
headers: {
|
|
Authorization: "Bearer " + accessToken,
|
|
...options?.headers,
|
|
},
|
|
});
|
|
const responseBody = await handleWebexResponse(response, credential.id);
|
|
return responseBody;
|
|
};
|
|
|
|
return {
|
|
getAvailability: async () => {
|
|
try {
|
|
const responseBody = await fetchWebexApi("meetings");
|
|
|
|
const data = webexMeetingsSchema.passthrough().parse(responseBody);
|
|
return data.items.map((meeting) => ({
|
|
start: meeting.start,
|
|
end: meeting.end,
|
|
}));
|
|
} catch (err) {
|
|
console.error(err);
|
|
|
|
return [];
|
|
}
|
|
},
|
|
createMeeting: async (event: CalendarEvent): Promise<VideoCallData> => {
|
|
/** @link https://developer.webex.com/docs/api/v1/meetings/create-a-meeting */
|
|
try {
|
|
console.log("Creating meeting", event);
|
|
console.log("meting body", translateEvent(event));
|
|
console.log("request body in createMeeting", JSON.stringify(translateEvent(event)));
|
|
const response = await fetchWebexApi("meetings", {
|
|
method: "POST",
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
},
|
|
body: JSON.stringify(translateEvent(event)),
|
|
});
|
|
console.log("Webex create meeting response", response);
|
|
if (response.error) {
|
|
if (response.error === "invalid_grant") {
|
|
await invalidateCredential(credential.id);
|
|
return Promise.reject(new Error("Invalid grant for Cal.com webex app"));
|
|
}
|
|
}
|
|
|
|
const result = webexEventResultSchema.parse(response);
|
|
if (result.id && result.webLink) {
|
|
return {
|
|
type: "webex_video",
|
|
id: result.id.toString(),
|
|
password: result.password || "",
|
|
url: result.webLink,
|
|
};
|
|
}
|
|
throw new Error("Failed to create meeting. Response is " + JSON.stringify(result));
|
|
} catch (err) {
|
|
console.error(err);
|
|
throw new Error("Unexpected error");
|
|
}
|
|
},
|
|
deleteMeeting: async (uid: string): Promise<void> => {
|
|
/** @link https://developer.webex.com/docs/api/v1/meetings/delete-a-meeting */
|
|
try {
|
|
const response = await fetchWebexApi(`meetings/${uid}`, {
|
|
method: "DELETE",
|
|
});
|
|
console.log("Webex delete meeting response", response);
|
|
if (response.error) {
|
|
if (response.error === "invalid_grant") {
|
|
await invalidateCredential(credential.id);
|
|
return Promise.reject(new Error("Invalid grant for Cal.com webex app"));
|
|
}
|
|
}
|
|
return Promise.resolve();
|
|
} catch (err) {
|
|
return Promise.reject(new Error("Failed to delete meeting"));
|
|
}
|
|
},
|
|
updateMeeting: async (bookingRef: PartialReference, event: CalendarEvent): Promise<VideoCallData> => {
|
|
/** @link https://developer.webex.com/docs/api/v1/meetings/update-a-meeting */
|
|
try {
|
|
const response = await fetchWebexApi(`meetings/${bookingRef.uid}`, {
|
|
method: "PATCH",
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
},
|
|
body: JSON.stringify(translateEvent(event)),
|
|
});
|
|
if (response.error) {
|
|
if (response.error === "invalid_grant") {
|
|
await invalidateCredential(credential.id);
|
|
return Promise.reject(new Error("Invalid grant for Cal.com webex app"));
|
|
}
|
|
}
|
|
|
|
const result = webexEventResultSchema.parse(response);
|
|
if (result.id && result.webLink) {
|
|
return {
|
|
type: "webex_video",
|
|
id: bookingRef.meetingId as string,
|
|
password: result.password || "",
|
|
url: result.webLink,
|
|
};
|
|
}
|
|
throw new Error("Failed to create meeting. Response is " + JSON.stringify(result));
|
|
} catch (err) {
|
|
console.error(err);
|
|
throw new Error("Unexpected error");
|
|
}
|
|
},
|
|
};
|
|
};
|
|
|
|
const handleWebexResponse = async (response: Response, credentialId: Credential["id"]) => {
|
|
let _response = response.clone();
|
|
const responseClone = response.clone();
|
|
if (_response.headers.get("content-encoding") === "gzip") {
|
|
const responseString = await response.text();
|
|
_response = JSON.parse(responseString);
|
|
}
|
|
if (!response.ok || (response.status < 200 && response.status >= 300)) {
|
|
const responseBody = await _response.json();
|
|
|
|
if ((response && response.status === 124) || responseBody.error === "invalid_grant") {
|
|
await invalidateCredential(credentialId);
|
|
}
|
|
throw Error(response.statusText);
|
|
}
|
|
// handle 204 response code with empty response (causes crash otherwise as "" is invalid JSON)
|
|
if (response.status === 204) {
|
|
return;
|
|
}
|
|
return responseClone.json();
|
|
};
|
|
|
|
const invalidateCredential = async (credentialId: Credential["id"]) => {
|
|
const credential = await prisma.credential.findUnique({
|
|
where: {
|
|
id: credentialId,
|
|
},
|
|
});
|
|
|
|
if (credential) {
|
|
await prisma.credential.update({
|
|
where: {
|
|
id: credentialId,
|
|
},
|
|
data: {
|
|
invalid: true,
|
|
},
|
|
});
|
|
}
|
|
};
|
|
export default WebexVideoApiAdapter;
|