* Initial draft of nextcloud app * Add video adapter for creating a new conversation via API * Make links dynamic, add nc user and pwd for auth, fix link creation * Add nextcloudTalk app to yarn.lock * Fix screenshot name * Add OAuth2 handler * Fix type for booking reference and fix access token header param, (for creating and deleting a meeting) * Remove comment * Add README.md file * Fix redirect URL for authenticating Nextcloud user --------- Co-authored-by: Amit Sharma <74371312+Amit91848@users.noreply.github.com> Co-authored-by: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com>
210 lines
7.7 KiB
TypeScript
210 lines
7.7 KiB
TypeScript
import { stringify } from "querystring";
|
|
import { v4 as uuidv4 } from "uuid";
|
|
import { z } from "zod";
|
|
|
|
import logger from "@calcom/lib/logger";
|
|
import { safeStringify } from "@calcom/lib/safeStringify";
|
|
import prisma from "@calcom/prisma";
|
|
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 getAppKeysFromSlug from "../../_utils/getAppKeysFromSlug";
|
|
import getParsedAppKeysFromSlug from "../../_utils/getParsedAppKeysFromSlug";
|
|
import { invalidateCredential } from "../../_utils/invalidateCredential";
|
|
import { OAuthManager } from "../../_utils/oauth/OAuthManager";
|
|
import { markTokenAsExpired } from "../../_utils/oauth/markTokenAsExpired";
|
|
import { oAuthManagerHelper } from "../../_utils/oauth/oAuthManagerHelper";
|
|
import config from "../config.json";
|
|
import { appKeysSchema } from "../zod";
|
|
|
|
const nextcloudEventResultSchema = z.object({
|
|
ocs: z.object({
|
|
meta: z.object({
|
|
status: z.string(),
|
|
statuscode: z.number(),
|
|
message: z.string(),
|
|
}),
|
|
data: z.object({
|
|
token: z.string(),
|
|
}),
|
|
}),
|
|
});
|
|
|
|
const NextcloudTalkVideoApiAdapter = (credential: CredentialPayload): VideoApiAdapter => {
|
|
const tokenResponse = oAuthManagerHelper.getTokenObjectFromCredential(credential);
|
|
|
|
const clientCredentials = getParsedAppKeysFromSlug(config.slug, appKeysSchema);
|
|
|
|
const fetchNextcloudApi = async (endpoint: string, options?: RequestInit) => {
|
|
const auth = new OAuthManager({
|
|
credentialSyncVariables: oAuthManagerHelper.credentialSyncVariables,
|
|
resourceOwner: {
|
|
type: "user",
|
|
id: credential.userId,
|
|
},
|
|
appSlug: config.slug,
|
|
currentTokenObject: tokenResponse,
|
|
fetchNewTokenObject: async ({ refreshToken }: { refreshToken: string | null }) => {
|
|
const { nextcloudTalkClientId, nextcloudTalkClientSecret, nextcloudTalkHost } =
|
|
await clientCredentials;
|
|
if (!refreshToken) {
|
|
return null;
|
|
}
|
|
const params = {
|
|
client_id: nextcloudTalkClientId,
|
|
client_secret: nextcloudTalkClientSecret,
|
|
refresh_token: refreshToken,
|
|
grant_type: "refresh_token",
|
|
};
|
|
const query = stringify(params);
|
|
return fetch(`${nextcloudTalkHost}/index.php/apps/oauth2/api/v1/token?${query}`, {
|
|
method: "POST",
|
|
headers: {
|
|
Accept: "application/json",
|
|
"Content-Type": "application/json",
|
|
},
|
|
body: new URLSearchParams({}),
|
|
});
|
|
},
|
|
isTokenObjectUnusable: async function (response) {
|
|
const myLog = logger.getSubLogger({ prefix: ["nextcloudtalkvideo:isTokenObjectUnusable"] });
|
|
myLog.debug(safeStringify({ status: response.status, ok: response.ok }));
|
|
if (!response.ok || (response.status < 200 && response.status >= 300)) {
|
|
const responseBody = await response.json();
|
|
myLog.debug(safeStringify({ responseBody }));
|
|
|
|
if (responseBody.error === "invalid_grant") {
|
|
return { reason: responseBody.error };
|
|
}
|
|
}
|
|
return null;
|
|
},
|
|
isAccessTokenUnusable: async function (response) {
|
|
const myLog = logger.getSubLogger({ prefix: ["nextcloudtalkvideo:isAccessTokenUnusable"] });
|
|
myLog.debug(safeStringify({ status: response.status, ok: response.ok }));
|
|
if (!response.ok || (response.status < 200 && response.status >= 300)) {
|
|
const responseBody = await response.json();
|
|
myLog.debug(safeStringify({ responseBody }));
|
|
|
|
if (responseBody.code === 124) {
|
|
return { reason: responseBody.message ?? "" };
|
|
}
|
|
}
|
|
return null;
|
|
},
|
|
invalidateTokenObject: () => invalidateCredential(credential.id),
|
|
expireAccessToken: () => markTokenAsExpired(credential),
|
|
updateTokenObject: async (newTokenObject) => {
|
|
await prisma.credential.update({
|
|
where: {
|
|
id: credential.id,
|
|
},
|
|
data: {
|
|
key: newTokenObject,
|
|
},
|
|
});
|
|
},
|
|
});
|
|
|
|
const { nextcloudTalkHost } = await clientCredentials;
|
|
const { json } = await auth.request({
|
|
url: `${nextcloudTalkHost}/${endpoint}`,
|
|
options: {
|
|
method: "GET",
|
|
...options,
|
|
headers: {
|
|
...options?.headers,
|
|
},
|
|
},
|
|
});
|
|
|
|
return json;
|
|
};
|
|
|
|
return {
|
|
getAvailability: () => {
|
|
return Promise.resolve([]);
|
|
},
|
|
createMeeting: async (eventData: CalendarEvent): Promise<VideoCallData> => {
|
|
const appKeys = await getAppKeysFromSlug(config.slug);
|
|
const meetingPattern = (appKeys.nextcloudTalkPattern as string) || "{uuid}";
|
|
const hostUrl = appKeys.nextcloudTalkHost as string;
|
|
|
|
//Allows "/{Type}-with-{Attendees}" slug
|
|
const meetingID = meetingPattern
|
|
.replaceAll("{uuid}", uuidv4())
|
|
.replaceAll("{Title}", eventData.title)
|
|
.replaceAll("{Event Type Title}", eventData.type)
|
|
.replaceAll("{Scheduler}", eventData.attendees.map((a) => a.name).join("-"))
|
|
.replaceAll("{Organizer}", eventData.organizer.name)
|
|
.replaceAll("{Location}", eventData.location || "")
|
|
.replaceAll("{Team}", eventData.team?.name || "")
|
|
.replaceAll(" ", "-"); //Last Rule! - Replace all blanks (%20) with dashes;
|
|
|
|
try {
|
|
// Create video link with room type 3 (constant for a public room, see https://nextcloud-talk.readthedocs.io/en/stable/constants/#conversation-types)
|
|
const response = await fetchNextcloudApi(`ocs/v2.php/apps/spreed/api/v4/room`, {
|
|
method: "POST",
|
|
headers: {
|
|
Accept: "application/json",
|
|
"Content-Type": "application/json",
|
|
"OCS-APIRequest": "true",
|
|
},
|
|
body: JSON.stringify({
|
|
roomType: 3,
|
|
roomName: `${meetingID}`,
|
|
}),
|
|
});
|
|
const result = nextcloudEventResultSchema.parse(response);
|
|
|
|
if (result.ocs && result.ocs.data) {
|
|
return {
|
|
type: config.type,
|
|
id: result.ocs.data.token,
|
|
password: "",
|
|
url: `${hostUrl}/call/${result.ocs.data.token}`,
|
|
};
|
|
}
|
|
throw new Error(`Failed to create meeting. Response is ${JSON.stringify(result)}`);
|
|
} catch (err) {
|
|
console.error(err);
|
|
/* Prevents meeting creation failure when token is expired */
|
|
throw new Error("Unexpected error");
|
|
}
|
|
},
|
|
deleteMeeting: async (uid: string): Promise<void> => {
|
|
try {
|
|
// Remove video link from Nextcloud
|
|
const response = await fetchNextcloudApi(`ocs/v2.php/apps/spreed/api/v4/room/${uid}`, {
|
|
method: "DELETE",
|
|
headers: {
|
|
Accept: "application/json",
|
|
"Content-Type": "application/json",
|
|
"OCS-APIRequest": "true",
|
|
},
|
|
});
|
|
const result = nextcloudEventResultSchema.parse(response);
|
|
|
|
if (result.ocs && result.ocs.meta && result.ocs.meta.status === "ok") {
|
|
return Promise.resolve();
|
|
}
|
|
throw new Error(`Failed to delete meeting. Response is ${JSON.stringify(result)}`);
|
|
} catch (err) {
|
|
return Promise.reject(new Error("Failed to delete meeting"));
|
|
}
|
|
},
|
|
updateMeeting: (bookingRef: PartialReference): Promise<VideoCallData> => {
|
|
return Promise.resolve({
|
|
type: config.type,
|
|
id: bookingRef.meetingId as string,
|
|
password: bookingRef.meetingPassword as string,
|
|
url: bookingRef.meetingUrl as string,
|
|
});
|
|
},
|
|
};
|
|
};
|
|
|
|
export default NextcloudTalkVideoApiAdapter;
|