-
Notifications
You must be signed in to change notification settings - Fork 13.9k
feat: add BigBlueButton video integration #29483
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from 3 commits
3cd6df2
b6058b0
fe8170c
1495b00
f719611
cbba79f
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,3 @@ | ||
| BigBlueButton is an open-source web conferencing system built for online learning and webinars. | ||
|
|
||
| This integration lets Cal.com create a BigBlueButton meeting URL for bookings. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,29 @@ | ||
| import type { AppMeta } from "@calcom/types/App"; | ||
|
|
||
| export const metadata = { | ||
| name: "BigBlueButton", | ||
| description: "Open-source video conferencing for online learning and webinars.", | ||
| installed: true, | ||
| type: "bigbluebutton_video", | ||
| variant: "conferencing", | ||
| categories: ["conferencing"], | ||
| logo: "icon.svg", | ||
| publisher: "Cal.diy", | ||
| url: "https://bigbluebutton.org/", | ||
| slug: "bigbluebutton", | ||
| title: "BigBlueButton", | ||
| isGlobal: false, | ||
| email: "help@cal.com", | ||
| appData: { | ||
| location: { | ||
| linkType: "dynamic", | ||
| type: "integrations:bigbluebutton", | ||
| label: "BigBlueButton", | ||
| }, | ||
| }, | ||
| dirName: "bigbluebutton", | ||
| concurrentMeetings: true, | ||
| isOAuth: false, | ||
| } as AppMeta; | ||
|
|
||
| export default metadata; | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,109 @@ | ||
| import { throwIfNotHaveAdminAccessToTeam } from "@calcom/app-store/_utils/throwIfNotHaveAdminAccessToTeam"; | ||
| import { ErrorCode } from "@calcom/lib/errorCodes"; | ||
| import { ErrorWithCode } from "@calcom/lib/errors"; | ||
| import { getServerErrorFromUnknown } from "@calcom/lib/server/getServerErrorFromUnknown"; | ||
| import prisma from "@calcom/prisma"; | ||
| import type { NextApiRequest, NextApiResponse } from "next"; | ||
| import getInstalledAppPath from "../../_utils/getInstalledAppPath"; | ||
|
|
||
| const getSingleQueryValue = (value: string | string[] | undefined) => | ||
| Array.isArray(value) ? value[0] : value; | ||
|
|
||
| const getTeamId = (value: string | string[] | undefined) => { | ||
| const rawTeamId = getSingleQueryValue(value); | ||
| if (!rawTeamId) { | ||
| return null; | ||
| } | ||
|
|
||
| if (!/^\d+$/.test(rawTeamId)) { | ||
| throw new ErrorWithCode(ErrorCode.BadRequest, "Invalid teamId"); | ||
| } | ||
|
|
||
| return Number(rawTeamId); | ||
| }; | ||
|
|
||
| const getSafeReturnTo = (value: string | string[] | undefined) => { | ||
| const returnTo = getSingleQueryValue(value); | ||
| if ( | ||
| !returnTo || | ||
| !returnTo.startsWith("/") || | ||
| returnTo.startsWith("//") || | ||
| /^[a-z][a-z\d+.-]*:/i.test(returnTo) | ||
| ) { | ||
| return getInstalledAppPath({ | ||
| variant: "conferencing", | ||
| slug: "bigbluebutton", | ||
| }); | ||
| } | ||
|
|
||
| return returnTo; | ||
| }; | ||
|
|
||
| export default async function handler( | ||
| req: NextApiRequest, | ||
| res: NextApiResponse, | ||
| ) { | ||
| if (!req.session?.user?.id) { | ||
| return res | ||
| .status(401) | ||
| .json({ message: "You must be logged in to do this" }); | ||
| } | ||
|
|
||
| const { teamId, returnTo } = req.query; | ||
|
|
||
| try { | ||
| const teamIdNumber = getTeamId(teamId); | ||
|
|
||
| await throwIfNotHaveAdminAccessToTeam({ | ||
| teamId: teamIdNumber, | ||
| userId: req.session.user.id, | ||
| }); | ||
|
|
||
| const installForObject = teamIdNumber | ||
| ? { teamId: teamIdNumber } | ||
| : { userId: req.session.user.id }; | ||
| const appType = "bigbluebutton_video"; | ||
|
|
||
| const alreadyInstalled = await prisma.credential.findFirst({ | ||
| where: { | ||
| type: appType, | ||
| ...installForObject, | ||
| }, | ||
| select: { | ||
| id: true, | ||
| }, | ||
| }); | ||
|
|
||
| if (alreadyInstalled) { | ||
| throw new ErrorWithCode(ErrorCode.BookingConflict, "Already installed"); | ||
| } | ||
|
|
||
| const installation = await prisma.credential.create({ | ||
| data: { | ||
| type: appType, | ||
| key: {}, | ||
| ...installForObject, | ||
| appId: "bigbluebutton", | ||
| }, | ||
| select: { | ||
| id: true, | ||
| }, | ||
| }); | ||
|
|
||
| if (!installation) { | ||
| throw new ErrorWithCode( | ||
| ErrorCode.InternalServerError, | ||
| "Unable to create user credential for bigbluebutton", | ||
| ); | ||
| } | ||
| } catch (error: unknown) { | ||
| const httpError = getServerErrorFromUnknown(error); | ||
| return res | ||
| .status(httpError.statusCode) | ||
| .json({ message: httpError.message }); | ||
| } | ||
|
|
||
| return res.status(200).json({ | ||
| url: getSafeReturnTo(returnTo), | ||
| }); | ||
| } | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1 @@ | ||
| export { default as add } from "./add"; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,3 @@ | ||
| export { metadata } from "./_metadata"; | ||
| export * as api from "./api"; | ||
| export * as lib from "./lib"; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,156 @@ | ||
| import { createHash, randomUUID } from "node:crypto"; | ||
| import type { CalendarEvent, EventBusyDate } from "@calcom/types/Calendar"; | ||
| import type { PartialReference } from "@calcom/types/EventManager"; | ||
| import type { | ||
| VideoApiAdapter, | ||
| VideoCallData, | ||
| } from "@calcom/types/VideoApiAdapter"; | ||
| import getAppKeysFromSlug from "../../_utils/getAppKeysFromSlug"; | ||
| import { metadata } from "../_metadata"; | ||
|
|
||
| type BigBlueButtonKeys = { | ||
| bigBlueButtonServerUrl?: string; | ||
| bigBlueButtonSharedSecret?: string; | ||
| }; | ||
|
|
||
| const normalizeApiUrl = (serverUrl: string) => { | ||
| const url = serverUrl.trim().replace(/\/+$/, ""); | ||
| return url.endsWith("/bigbluebutton/api") ? url : `${url}/bigbluebutton/api`; | ||
| }; | ||
|
|
||
| const checksum = (method: string, query: string, sharedSecret: string) => | ||
| createHash("sha1").update(`${method}${query}${sharedSecret}`).digest("hex"); | ||
|
|
||
| const deriveMeetingPassword = ( | ||
| role: "attendee" | "moderator", | ||
| meetingID: string, | ||
| sharedSecret: string, | ||
| ) => | ||
| createHash("sha256") | ||
| .update(`${role}:${meetingID}:${sharedSecret}`) | ||
| .digest("hex") | ||
| .slice(0, 24); | ||
|
|
||
| const buildApiUrl = ( | ||
| apiUrl: string, | ||
| method: string, | ||
| params: URLSearchParams, | ||
| sharedSecret: string, | ||
| ) => { | ||
| const query = params.toString(); | ||
| const signedQuery = new URLSearchParams(params); | ||
| signedQuery.set("checksum", checksum(method, query, sharedSecret)); | ||
| return `${apiUrl}/${method}?${signedQuery.toString()}`; | ||
| }; | ||
|
|
||
| const assertSuccessResponse = async (response: Response) => { | ||
| const body = await response.text(); | ||
|
|
||
| if (!response.ok || !body.includes("<returncode>SUCCESS</returncode>")) { | ||
| throw new Error(`BigBlueButton API request failed: ${body}`); | ||
| } | ||
| }; | ||
|
|
||
| const getBigBlueButtonConfig = async () => { | ||
| const appKeys = (await getAppKeysFromSlug( | ||
| metadata.slug, | ||
| )) as BigBlueButtonKeys; | ||
|
Comment on lines
+56
to
+59
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Read BigBlueButton keys from the installed credential, not from the app slug. This adapter is created per credential, but Also applies to: 71-77 🤖 Prompt for AI Agents |
||
| const serverUrl = appKeys.bigBlueButtonServerUrl?.trim(); | ||
| const sharedSecret = appKeys.bigBlueButtonSharedSecret?.trim(); | ||
|
|
||
| if (!serverUrl || !sharedSecret) { | ||
| throw new Error("BigBlueButton server URL and shared secret are required"); | ||
| } | ||
|
|
||
| return { | ||
| apiUrl: normalizeApiUrl(serverUrl), | ||
| sharedSecret, | ||
| }; | ||
| }; | ||
|
|
||
| const BigBlueButtonVideoApiAdapter = (): VideoApiAdapter => { | ||
| return { | ||
| getAvailability: (): Promise<EventBusyDate[]> => { | ||
| return Promise.resolve([]); | ||
| }, | ||
| createMeeting: async (eventData: CalendarEvent): Promise<VideoCallData> => { | ||
| const { apiUrl, sharedSecret } = await getBigBlueButtonConfig(); | ||
| const meetingID = eventData.uid || randomUUID(); | ||
| const attendeePassword = deriveMeetingPassword( | ||
| "attendee", | ||
| meetingID, | ||
| sharedSecret, | ||
| ); | ||
| const moderatorPassword = deriveMeetingPassword( | ||
| "moderator", | ||
| meetingID, | ||
| sharedSecret, | ||
| ); | ||
|
|
||
| const createParams = new URLSearchParams({ | ||
| name: eventData.title, | ||
| meetingID, | ||
| attendeePW: attendeePassword, | ||
| moderatorPW: moderatorPassword, | ||
| record: "false", | ||
| }); | ||
|
|
||
| const createUrl = buildApiUrl( | ||
| apiUrl, | ||
| "create", | ||
| createParams, | ||
| sharedSecret, | ||
| ); | ||
| await assertSuccessResponse(await fetch(createUrl)); | ||
|
|
||
| const joinParams = new URLSearchParams({ | ||
| fullName: "Guest", | ||
| meetingID, | ||
| password: attendeePassword, | ||
| redirect: "true", | ||
| }); | ||
|
|
||
| return { | ||
| type: metadata.type, | ||
| id: meetingID, | ||
| password: moderatorPassword, | ||
| url: buildApiUrl(apiUrl, "join", joinParams, sharedSecret), | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
| }; | ||
| }, | ||
| deleteMeeting: async (uid: string): Promise<void> => { | ||
| const { apiUrl, sharedSecret } = await getBigBlueButtonConfig(); | ||
| const moderatorPassword = deriveMeetingPassword( | ||
| "moderator", | ||
| uid, | ||
| sharedSecret, | ||
| ); | ||
|
|
||
| const endParams = new URLSearchParams({ | ||
| meetingID: uid, | ||
| password: moderatorPassword, | ||
| }); | ||
|
|
||
| await assertSuccessResponse( | ||
| await fetch(buildApiUrl(apiUrl, "end", endParams, sharedSecret)), | ||
| ); | ||
| }, | ||
| updateMeeting: (bookingRef: PartialReference): Promise<VideoCallData> => { | ||
| const { meetingId, meetingPassword, meetingUrl } = bookingRef; | ||
|
|
||
| if (!meetingId || !meetingPassword || !meetingUrl) { | ||
| throw new Error( | ||
| "BigBlueButton booking reference is missing meeting data", | ||
| ); | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
| } | ||
|
|
||
| return Promise.resolve({ | ||
| type: metadata.type, | ||
| id: meetingId, | ||
| password: meetingPassword, | ||
| url: meetingUrl, | ||
| }); | ||
| }, | ||
| }; | ||
| }; | ||
|
|
||
| export default BigBlueButtonVideoApiAdapter; | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1 @@ | ||
| export { default as VideoApiAdapter } from "./VideoApiAdapter"; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,13 @@ | ||
| { | ||
| "private": true, | ||
| "name": "@calcom/bigbluebutton", | ||
| "version": "0.0.0", | ||
| "main": "./index.ts", | ||
| "description": "BigBlueButton is an open-source web conferencing system built for online learning and webinars.", | ||
| "dependencies": { | ||
| "@calcom/lib": "workspace:*" | ||
| }, | ||
| "devDependencies": { | ||
| "@calcom/types": "workspace:*" | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,8 @@ | ||
| import { z } from "zod"; | ||
|
|
||
| export const appKeysSchema = z.object({ | ||
| bigBlueButtonServerUrl: z.string().url().optional(), | ||
| bigBlueButtonSharedSecret: z.string().min(1).optional(), | ||
| }); | ||
|
|
||
| export const appDataSchema = z.object({}); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Localize the location label.
appData.location.labelis a user-facing string, so hardcoding"BigBlueButton"here skips the app’s i18n flow. Please add it topackages/i18n/locales/en/common.jsonand reference the translated value instead.As per coding guidelines, "
**/*.{ts,tsx,jsx}: Add translations topackages/i18n/locales/en/common.jsonfor all UI strings".🤖 Prompt for AI Agents