feat: add nextcloud talk app (#16455)

* 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>
This commit is contained in:
CodeShakingSheep
2024-10-09 17:52:30 +05:30
committed by GitHub
co-authored by Amit Sharma Anik Dhabal Babu
parent 1a4cf5f91d
commit de503add83
21 changed files with 472 additions and 0 deletions
@@ -23,6 +23,7 @@ import { appKeysSchema as make_zod_ts } from "./make/zod";
import { appKeysSchema as matomo_zod_ts } from "./matomo/zod";
import { appKeysSchema as metapixel_zod_ts } from "./metapixel/zod";
import { appKeysSchema as mock_payment_app_zod_ts } from "./mock-payment-app/zod";
import { appKeysSchema as nextcloudtalk_zod_ts } from "./nextcloudtalk/zod";
import { appKeysSchema as office365calendar_zod_ts } from "./office365calendar/zod";
import { appKeysSchema as office365video_zod_ts } from "./office365video/zod";
import { appKeysSchema as paypal_zod_ts } from "./paypal/zod";
@@ -70,6 +71,7 @@ export const appKeysSchemas = {
matomo: matomo_zod_ts,
metapixel: metapixel_zod_ts,
"mock-payment-app": mock_payment_app_zod_ts,
nextcloudtalk: nextcloudtalk_zod_ts,
office365calendar: office365calendar_zod_ts,
office365video: office365video_zod_ts,
paypal: paypal_zod_ts,
@@ -48,6 +48,7 @@ import metapixel_config_json from "./metapixel/config.json";
import mirotalk_config_json from "./mirotalk/config.json";
import mock_payment_app_config_json from "./mock-payment-app/config.json";
import n8n_config_json from "./n8n/config.json";
import nextcloudtalk_config_json from "./nextcloudtalk/config.json";
import { metadata as office365calendar__metadata_ts } from "./office365calendar/_metadata";
import office365video_config_json from "./office365video/config.json";
import paypal_config_json from "./paypal/config.json";
@@ -144,6 +145,7 @@ export const appStoreMetadata = {
mirotalk: mirotalk_config_json,
"mock-payment-app": mock_payment_app_config_json,
n8n: n8n_config_json,
nextcloudtalk: nextcloudtalk_config_json,
office365calendar: office365calendar__metadata_ts,
office365video: office365video_config_json,
paypal: paypal_config_json,
@@ -23,6 +23,7 @@ import { appDataSchema as make_zod_ts } from "./make/zod";
import { appDataSchema as matomo_zod_ts } from "./matomo/zod";
import { appDataSchema as metapixel_zod_ts } from "./metapixel/zod";
import { appDataSchema as mock_payment_app_zod_ts } from "./mock-payment-app/zod";
import { appDataSchema as nextcloudtalk_zod_ts } from "./nextcloudtalk/zod";
import { appDataSchema as office365calendar_zod_ts } from "./office365calendar/zod";
import { appDataSchema as office365video_zod_ts } from "./office365video/zod";
import { appDataSchema as paypal_zod_ts } from "./paypal/zod";
@@ -70,6 +71,7 @@ export const appDataSchemas = {
matomo: matomo_zod_ts,
metapixel: metapixel_zod_ts,
"mock-payment-app": mock_payment_app_zod_ts,
nextcloudtalk: nextcloudtalk_zod_ts,
office365calendar: office365calendar_zod_ts,
office365video: office365video_zod_ts,
paypal: paypal_zod_ts,
@@ -48,6 +48,7 @@ export const apiHandlers = {
mirotalk: import("./mirotalk/api"),
"mock-payment-app": import("./mock-payment-app/api"),
n8n: import("./n8n/api"),
nextcloudtalk: import("./nextcloudtalk/api"),
office365calendar: import("./office365calendar/api"),
office365video: import("./office365video/api"),
paypal: import("./paypal/api"),
@@ -22,6 +22,7 @@ import { metadata as jitsivideo__metadata_ts } from "./jitsivideo/_metadata";
import matomo_config_json from "./matomo/config.json";
import metapixel_config_json from "./metapixel/config.json";
import mirotalk_config_json from "./mirotalk/config.json";
import nextcloudtalk_config_json from "./nextcloudtalk/config.json";
import office365video_config_json from "./office365video/config.json";
import ping_config_json from "./ping/config.json";
import plausible_config_json from "./plausible/config.json";
@@ -66,6 +67,7 @@ export const appStoreMetadata = {
matomo: matomo_config_json,
metapixel: metapixel_config_json,
mirotalk: mirotalk_config_json,
nextcloudtalk: nextcloudtalk_config_json,
office365video: office365video_config_json,
ping: ping_config_json,
plausible: plausible_config_json,
+1
View File
@@ -15,6 +15,7 @@ const appStore = {
jellyconferencing: () => import("./jelly"),
jitsivideo: () => import("./jitsivideo"),
larkcalendar: () => import("./larkcalendar"),
nextcloudtalkvideo: () => import("./nextcloudtalk"),
office365calendar: () => import("./office365calendar"),
office365video: () => import("./office365video"),
plausible: () => import("./plausible"),
@@ -0,0 +1,7 @@
---
items:
- nextcloudtalk1.jpg
- nextcloudtalk2.jpg
---
{DESCRIPTION}
@@ -0,0 +1,11 @@
### Obtaining Nextcloud Client ID and Secret
1. Create a [Nextcloud account](https://nextcloud.com/sign-up), if you don't already have one or [self-host Nextcloud](https://nextcloud.com/install/#instructions-server).
2. Sign in as admin on your Nextcloud.
3. On the upper right, click the profile icon and go to "Administration settings".
4. In the left panel under "Administration" click on "Security".
5. Scroll down until the end of the page to "OAuth 2.0 clients".
6. Under "Add client" give your client a name, e.g. "Cal.com".
7. Set the Redirection URI as `<Cal.com URL>/api/integrations/nextcloudtalk/callback` replacing Cal.com URL with the URI at which your application runs.
8. Click "Add".
9. Copy the Client Identifier and Secret key and add these while enabling the app through Settings -> Admin -> Apps interface
@@ -0,0 +1,36 @@
import type { NextApiRequest } from "next";
import { stringify } from "querystring";
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";
async function handler(req: NextApiRequest) {
const user = req?.session?.user;
if (!user) {
return { status: 401, body: { error: "Unauthorized" } };
}
const appKeys = await getAppKeysFromSlug("nextcloudtalk");
const hostUrl = appKeys.nextcloudTalkHost as string;
const client_id = appKeys.nextcloudTalkClientId as string;
const client_secret = appKeys.nextcloudTalkClientSecret as string;
const state = encodeOAuthState(req);
const params = {
response_type: "code",
client_id,
client_secret,
redirect_uri: `${WEBAPP_URL}/api/integrations/nextcloudtalk/callback`,
state,
};
const query = stringify(params);
const url = `${hostUrl}/index.php/apps/oauth2/authorize?${query}`;
return { url };
}
export default defaultHandler({
GET: Promise.resolve({ default: defaultResponder(handler) }),
});
@@ -0,0 +1,70 @@
import type { NextApiRequest, NextApiResponse } from "next";
import { WEBAPP_URL } from "@calcom/lib/constants";
import { getSafeRedirectUrl } from "@calcom/lib/getSafeRedirectUrl";
import prisma from "@calcom/prisma";
import getAppKeysFromSlug from "../../_utils/getAppKeysFromSlug";
import getInstalledAppPath from "../../_utils/getInstalledAppPath";
import createOAuthAppCredential from "../../_utils/oauth/createOAuthAppCredential";
import { decodeOAuthState } from "../../_utils/oauth/decodeOAuthState";
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
const state = decodeOAuthState(req);
const userId = req.session?.user.id;
if (!userId) {
return res.status(404).json({ message: "No user found" });
}
const { code } = req.query;
const appKeys = await getAppKeysFromSlug("nextcloudtalk");
const hostUrl = appKeys.nextcloudTalkHost as string;
const client_id = appKeys.nextcloudTalkClientId as string;
const client_secret = appKeys.nextcloudTalkClientSecret as string;
const result = await fetch(`${hostUrl}/index.php/apps/oauth2/api/v1/token`, {
method: "POST",
body: JSON.stringify({
grant_type: "authorization_code",
code,
client_id,
client_secret,
redirect_uri: `${WEBAPP_URL}/api/integrations/nextcloudtalk/callback`,
}),
headers: {
"Content-Type": "application/json",
},
});
if (result.status !== 200) {
let errorMessage = "Something is wrong with the Nextcloud API";
try {
const responseBody = await result.json();
errorMessage = responseBody.error;
} catch (e) {}
res.status(400).json({ message: errorMessage });
return;
}
const responseBody = await result.json();
if (responseBody.error) {
res.status(400).json({ message: responseBody.error });
return;
}
// Remove the previous credential if there were linked ones already
await prisma.credential.deleteMany({
where: {
type: "nextcloudtalk_video",
userId,
appId: "nextcloudtalk",
},
});
await createOAuthAppCredential({ appId: "nextcloudtalk", type: "nextcloudtalk_video" }, responseBody, req);
res.redirect(
getSafeRedirectUrl(state?.returnTo) ??
getInstalledAppPath({ variant: "conferencing", slug: "nextcloudtalk" })
);
}
@@ -0,0 +1,2 @@
export { default as add } from "./add";
export { default as callback } from "./callback";
@@ -0,0 +1,22 @@
{
"/*": "Don't modify slug - If required, do it using cli edit command",
"name": "Nextcloud Talk",
"slug": "nextcloudtalk",
"type": "nextcloudtalk_video",
"logo": "icon.svg",
"url": "https://nextcloud.com/talk",
"variant": "conferencing",
"categories": ["conferencing"],
"publisher": "Cal.com",
"email": "help@cal.com",
"appData": {
"location": {
"type": "integrations:{SLUG}_conferencing",
"label": "{TITLE}",
"linkType": "dynamic"
}
},
"description": "Nextcloud Talk is a fully self hosted, on-premises audio/video and chat communication service. It features web and mobile apps and is designed to offer the highest degree of security while being easy to use.",
"isTemplate": false,
"__createdUsingCli": true
}
@@ -0,0 +1,2 @@
export * as api from "./api";
export * as lib from "./lib";
@@ -0,0 +1,209 @@
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;
@@ -0,0 +1 @@
export { default as VideoApiAdapter } from "./VideoApiAdapter";
@@ -0,0 +1,14 @@
{
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"name": "@calcom/nextcloudtalk",
"version": "0.0.0",
"main": "./index.ts",
"dependencies": {
"@calcom/lib": "*"
},
"devDependencies": {
"@calcom/types": "*"
},
"description": "Nextcloud Talk is a fully self hosted, on-premises audio/video and chat communication service. It features web and mobile apps and is designed to offer the highest degree of security while being easy to use."
}
@@ -0,0 +1,69 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
inkscape:version="1.1-dev (f9311a1, 2019-12-25)"
sodipodi:docname="talk8.svg"
id="svg19"
xml:space="preserve"
viewBox="0 0 1024 1024"
version="1.1"
stroke-miterlimit="1.4142"
stroke-linejoin="round"
fill-rule="evenodd"
clip-rule="evenodd"><metadata
id="metadata23"><rdf:RDF><cc:Work
rdf:about=""><dc:format>image/svg+xml</dc:format><dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" /><dc:title></dc:title></cc:Work></rdf:RDF></metadata><sodipodi:namedview
inkscape:current-layer="svg19"
inkscape:window-maximized="1"
inkscape:window-y="23"
inkscape:window-x="1440"
inkscape:cy="522.40348"
inkscape:cx="510.51379"
inkscape:zoom="0.67285156"
showgrid="false"
id="namedview21"
inkscape:window-height="1035"
inkscape:window-width="1920"
inkscape:pageshadow="2"
inkscape:pageopacity="0"
guidetolerance="10"
gridtolerance="10"
objecttolerance="10"
borderopacity="1"
inkscape:document-rotation="0"
bordercolor="#666666"
pagecolor="#ffffff" /><defs
id="defs15"><linearGradient
gradientUnits="userSpaceOnUse"
gradientTransform="matrix(8.96 0 0 8.96 -7.8457e-5 .00019795)"
y2="-7.6294e-6"
y1="150"
x2="150"
x1="18.23"
id="a"><stop
id="stop10"
offset="0"
stop-color="#0082c9" /><stop
id="stop12"
offset="1"
stop-color="#1cafff" /></linearGradient></defs>
<rect
id="rect17"
fill-rule="evenodd"
fill="url(#a)"
height="1024"
width="1024"
rx="160" /><path
style="fill:#ffffff"
inkscape:connector-curvature="0"
d="M 511.95919,186 A 325.96385,325.95103 0 0 0 186,511.96034 325.96385,325.95103 0 0 0 511.95919,837.91133 325.96385,325.95103 0 0 0 681.04889,790.22529 c 40.06218,15.91895 129.79781,63.14682 151.15526,42.74701 22.3177,-21.31206 -26.20129,-121.61808 -37.83331,-158.89148 A 325.96385,325.95103 0 0 0 837.91466,511.95755 325.96385,325.95103 0 0 0 511.96013,186.01118 Z m 0.0373,123.92323 A 202.1178,202.11161 0 0 1 714.11425,512.03485 202.1178,202.11161 0 0 1 511.99645,714.13247 202.1178,202.11161 0 0 1 309.87866,512.03485 202.1178,202.11161 0 0 1 511.99645,309.92323 Z"
stroke-width="0.14"
fill="#000"
id="path25" /></svg>

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 194 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 334 KiB

+10
View File
@@ -0,0 +1,10 @@
import { z } from "zod";
export const appKeysSchema = z.object({
nextcloudTalkHost: z.string(),
nextcloudTalkPattern: z.string().optional(),
nextcloudTalkClientId: z.string(),
nextcloudTalkClientSecret: z.string(),
});
export const appDataSchema = z.object({});
+9
View File
@@ -5051,6 +5051,15 @@ __metadata:
languageName: unknown
linkType: soft
"@calcom/nextcloudtalk@workspace:packages/app-store/nextcloudtalk":
version: 0.0.0-use.local
resolution: "@calcom/nextcloudtalk@workspace:packages/app-store/nextcloudtalk"
dependencies:
"@calcom/lib": "*"
"@calcom/types": "*"
languageName: unknown
linkType: soft
"@calcom/office365calendar@workspace:packages/app-store/office365calendar":
version: 0.0.0-use.local
resolution: "@calcom/office365calendar@workspace:packages/app-store/office365calendar"