Merge pull request #413 from anoadragon453/anoa/bbb_call_button

BigBlueButton: Allow creating meetings from the client
This commit is contained in:
Travis Ralston 2021-05-10 19:38:04 -06:00 committed by GitHub
commit b97a0f4bc2
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
15 changed files with 677 additions and 42 deletions

View file

@ -91,6 +91,26 @@ dimension:
# to your own Dimension instance.
publicUrl: "https://dimension.example.org"
bigbluebutton:
# The full base URL of the API of your BigBlueButton instance. The API is
# used to create and join meetings.
apiBaseUrl: "https://bbb.example.org/bigbluebutton/api"
# The "shared secret" of your BigBlueButton instance. This is used to
# authenticate to the API above.
sharedSecret: "YourSharedSecretHere"
# The title for BigBlueButton widgets that are generated by Dimension.
widgetName: "BigBlueButton Conference"
# The subtitle for BigBlueButton widgets that are generated by Dimension.
widgetTitle: "Join the conference"
# The avatar for BigBlueButton widgets that are generated by Dimension.
# Usually this doen't need to be changed, however if your homeserver
# is not able to reach t2bot.io then you should specify your own here.
widgetAvatarUrl: "mxc://t2bot.io/be1650140620d8bb61a8cf5baeb05f24a734434c"
# Settings for controlling how logging works
logging:
file: logs/dimension.log

218
docs/bigbluebutton.md Normal file
View file

@ -0,0 +1,218 @@
# BigBlueButton
[BigBlueButton](https://bigbluebutton.org/) is open-source video calling software aimed primarily at educators and has
useful features such as screen and PDF sharing, collaborative document editing, polls and more.
Dimension supports embedding BigBlueButton meetings into a Matrix room via widgets, and can do so in two ways:
* [Greenlight](https://docs.bigbluebutton.org/greenlight/gl-overview.html) is a frontend for BigBlueButton meeting
administration that allows for creating/ending meetings, inviting people via link or email etc. Dimension supports
taking a Greenlight URL and turning it into a widget in a Matrix room that room occupants can use to join the meeting.
In this instance, Greenlight is acting as a client of BigBlueButton's server-side API, and Dimension is relying on a
Greenlight URL to allow Matrix users to join a meeting.
* Alternatively, Dimension can be configured to connect directly to BigBlueButton and create meetings itself. This does
require running your own BigBlueButton server which Dimension will need authorization to control. This configuration is
useful as meetings can be created without needing to leave your Matrix client, as well as sidesteps the need to set up
Greenlight on your deployment. This method can also be used to allow the 'Video Call' button in Element to start a
meeting.
Note that with the first method, Dimension can be given a Greenlight URL pointing to any public instance of
Greenlight/BigBlueButton. With the second, Dimension will only talk to, and create meetings on, the BigBlueButton server
it has been configured to talk to.
## Usage Guide
### Using a Greenlight URL
Dimension does not require any extra configuration to allow creating meetings using a Greenlight URL.
* Open the integration manager window and add a BigBlueButton widget.
* Enter the URL you received from Greenlight. It should be in the form: `https://bbb.example.com/abc-def-ghi`.
* Click the 'Add Widget' button.
Members in the room will see a BigBlueButton widget appear. They can click "Join Conference" inside the widget to join.
### Have Dimension Create Meetings (with Element's Video Call button)
Fill out the `bigbluebutton` section of Dimension's config file. Both the `apiBaseUrl` and `sharedSecret` fields must be
set to the values corresponding to your own BigBlueButton server.
Element can be configured to ask Dimension to start a meeting without any client-side changes. Simply add the following
information to the [Client well-known](https://matrix.org/docs/spec/client_server/r0.6.1#get-well-known-matrix-client)
file of your homeserver:
```json
"io.element.call_behaviour": {
"widget_build_url": "https://dimension.example.com/api/v1/dimension/bigbluebutton/widget_state",
}
```
then close and reopen Element so that it picks up the new behaviour.
Now pressing the Video Call button in a room with more than two users in it should spawn a BigBlueButton meeting! Note
that BigBlueButton will automatically end meetings that all users have left (after a minute or two). Thus if all users
have left a meeting, you will need to recreate the widget in order to spawn a new meeting.
Created meetings are protected by a generated password that is included in the widget room state event. Thus one will
only be able to gain access to the meeting if they are in the room, or if someone in the room shares that information
with them. All users that join will have moderator permissions in the meeting, though this may change in the future to
allow meeting permissions based of room power level.
### Troubleshooting
This code was last tested with the [BigBlueButton v2.3 docker image](https://github.com/bigbluebutton/docker/).
#### When using a Greenlight URL
Let us know if you run into any problems!
#### When having Dimension create meetings
*My users are seeing 401 Unknown Session errors when trying to join a meeting!*
BigBlueButton (or at least the [docker image](https://github.com/bigbluebutton/docker)) has a strange bug where
calling the [`/join`](https://docs.bigbluebutton.org/dev/api.html#join) API ends up failing to verify the session ID
*which it just passed to you*. There's discussion on the
issue [here](https://github.com/bigbluebutton/bigbluebutton/issues/6343)
and [here](https://groups.google.com/forum/#!topic/bigbluebutton-dev/TkjyUZP_gO8), but **TL;DR if you're getting
invalid session ID errors**, a workaround is to set `allowRequestsWithoutSession=true`
in `/usr/share/bbb-web/WEB-INF/classes/bigbluebutton.properties` (located in the `bbb-docker_bbb-web` container for
docker deployments).
*I'm using the BigBlueButton docker image and it's taking about 20-30 seconds for a user to join a meeting. Why is it so slow?*
This appears to be due to the BigBlueButton nginx container attempting to contact multiple html5-frontend instances, of
which you may only have configured one or two to start. nginx will wait for every request to timeout before returning
the result to yours though, hence the long wait. It's possible to edit the nginx configuration inside the containers to
remove the extra upstream directives and eliminate the timeout. But this is something that really needs to be fixed
upstream.
*It sometimes says that my meeting does not exist! Where'd it go?*
As mentioned above, BigBlueButton server will automatically remove meetings that everyone has left. Unfortunately we don't
currently have a way to remove the widget when this happens. Someone will permissions in the room will need to recreate
the widget and thus create a new meeting.
## Implementation Details
### When using a Greenlight URL
See the explanation [here](https://github.com/turt2live/matrix-dimension/blob/70608c2a96a11953e0bf3bf197bb81d852df801d/src/api/dimension/DimensionBigBlueButtonService.ts#L28-L111).
### When having Dimension create meetings
Matrix clients can create widgets by sending a widget state event in the room with the appropriate fields. To retrieve
the necessary content of a state event that embeds a BigBlueButton meeting, clients can
call `GET /api/v1/dimension/bigbluebutton/widget_state?roomId=!room:domain`
on Dimension to retrieve the necessary json contents for the state event. An example response may look like:
```json
{
"widget_id": "24faa4cfd11d3b915664b7b393866974517014d43e5e682f8c930ec3fbaac337",
"widget": {
"creatorUserId": "@dimension:localhost",
"id": "24faa4cfd11d3b915664b7b393866974517014d43e5e682f8c930ec3fbaac337",
"type": "m.custom",
"waitForIframeLoad": true,
"name": "BigBlueButton Conference",
"avatar_url": "mxc://t2bot.io/be1650140620d8bb61a8cf5baeb05f24a734434c",
"url": "https://dimension.example.com/widgets/bigbluebutton?widgetId=$matrix_widget_id&roomId=$matrix_room_id&createMeeting=true&displayName=$matrix_display_name&avatarUrl=$matrix_avatar_url&userId=$matrix_user_id&meetingId=GsmiDReG&meetingPassword=dvKIv7EX&auth=$openidtoken-jwt",
"data": {
"title": "Join the conference"
}
},
"layout": {
"container": "top",
"index": 0,
"width": 65,
"height": 50
}
}
```
The contents of the `widget` dict from the response is what should be placed in the `content` of the widget state
event. An example widget state event generated from the above looks like:
```json
{
"type": "im.vector.modular.widgets",
"sender": "@admin:localhost",
"content": {
"creatorUserId": "@admin:localhost",
"id": "24faa4cfd11d3b915664b7b393866974517014d43e5e682f8c930ec3fbaac337",
"type": "m.custom",
"waitForIframeLoad": true,
"name": "BigBlueButton Conference",
"avatar_url": "mxc://t2bot.io/be1650140620d8bb61a8cf5baeb05f24a734434c",
"url": "https://dimension.example.com/widgets/bigbluebutton?widgetId=$matrix_widget_id&roomId=$matrix_room_id&createMeeting=true&displayName=$matrix_display_name&avatarUrl=$matrix_avatar_url&userId=$matrix_user_id&meetingId=GsmiDReG&meetingPassword=dvKIv7EX&auth=$openidtoken-jwt",
"data": {
"title": "Join the conference"
},
"roomId": "!ZsCMQAoIIHgOlXMzwX:localhost",
"eventId": "$2RbnJDUPFIMTDVda_-Z01lyZt30W-bZnw3z7CFIRWKQ"
},
"state_key": "24faa4cfd11d3b915664b7b393866974517014d43e5e682f8c930ec3fbaac337",
"origin_server_ts": 1620386456620,
"unsigned": {
"age": 96
},
"event_id": "$2RbnJDUPFIMTDVda_-Z01lyZt30W-bZnw3z7CFIRWKQ",
"room_id": "!ZsCMQAoIIHgOlXMzwX:localhost"
}
```
While servicing the `/widget_state` call, Dimension will create the BigBlueButton meeting by calling the
[BigBlueButton `/create` API](https://docs.bigbluebutton.org/dev/api.html#create). We get back a meeting ID and two
passwords from BigBlueButton: one "attendee" and one "moderator" password. We can pass either of these back to the user,
and it'll be placed in the widget state event in the room. (For now, we just pass back the moderator password). You'll
notice that it's included in the `url` field as a query parameter. Those query parameters are passed to the widget when
it loads, which the widget then uses to populate fields when making subsequent calls to Dimension.
The widget will then use the meetingID, password and some additional user metadata (displayname, userID, avatarURL) to call
`POST /api/v1/dimension/bigbluebutton/getJoinUrl`. Dimension will craft a URL that the widget can use to call the
[BigBlueButton `/join` API](https://docs.bigbluebutton.org/dev/api.html#join), and respond with the following:
```json
{
"url": "https://bbb.example.com/bigbluebutton/api/join?meetingID=2QdrCVAl&password=yX2w587M&fullName=bob%20(%40bob%3Aexample.com)&userID=%40bob%3Aexample.com&checksum=fa35ecdf711478423bf5173cb81c5b2e0e9e59bb8779811b614a44645ee94d89"
}
```
That URL is embedded and loaded by the widget - joining the meeting.
Note that if everyone in the meeting leaves, the meeting will be garbage-collected automatically server-side by BigBlueButton.
However the widget will still remain in the room. In this case, if users attempt to join the meeting again, they will be
informed that a new meeting needs to be created. This works by having Dimension check if a meeting is still running in
`/getJoinUrl`. It does so by using the
[BigBlueButton `getMeetingInfo` API](https://docs.bigbluebutton.org/dev/api.html#getmeetinginfo) to check that:
* A current or past meeting actually exists with the provided meeting ID - if not, the following will be returned to the
widget:
```json
{
"jsonResponse": {
"error": "This meeting does not exist.",
"dim_errcode": "UNKNOWN_MEETING_ID",
"errcode": "UNKNOWN_MEETING_ID"
},
"statusCode": 400,
"errorCode": "UNKNOWN_MEETING_ID"
}
```
* A meeting exists, but has both `running` as `false` and has an `endTime` other than 0. If both of those are true,
then the meeting existed but is no longer running. In that case, the following will be returned to the widget:
```json
{
"jsonResponse": {
"error": "This meeting does not exist.",
"dim_errcode": "MEETING_HAS_ENDED",
"errcode": "MEETING_HAS_ENDED"
},
"statusCode": 400,
"errorCode": "MEETING_HAS_ENDED"
}
```
Upon receiving either of these, the widget will inform the user with an error message that a new meeting must be created.

14
package-lock.json generated
View file

@ -13962,6 +13962,20 @@
"async-limiter": "~1.0.0"
}
},
"xml2js": {
"version": "0.4.23",
"resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.4.23.tgz",
"integrity": "sha512-ySPiMjM0+pLDftHgXY4By0uswI3SPKLDw/i3UXbnO8M/p28zqexCUoPmQFrYD+/1BzhGJSs2i1ERWKJAtiLrug==",
"requires": {
"sax": ">=0.6.0",
"xmlbuilder": "~11.0.0"
}
},
"xmlbuilder": {
"version": "11.0.1",
"resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-11.0.1.tgz",
"integrity": "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA=="
},
"xtend": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.1.tgz",

View file

@ -61,7 +61,8 @@
"typescript-ioc": "^1.2.5",
"typescript-rest": "^2.2.0",
"umzug": "^2.2.0",
"url": "^0.11.0"
"url": "^0.11.0",
"xml2js": "^0.4.23"
},
"devDependencies": {
"@angular/animations": "^8.0.3",

View file

@ -1,11 +1,16 @@
import { GET, Path, QueryParam } from "typescript-rest";
import { GET, POST, Path, QueryParam } from "typescript-rest";
import * as request from "request";
import { LogService } from "matrix-js-snippets";
import { URL } from "url";
import { BigBlueButtonJoinRequest } from "../../models/Widget";
import { BigBlueButtonJoinResponse } from "../../models/WidgetResponses";
import { BigBlueButtonGetJoinUrlRequest } from "../../models/Widget";
import { BigBlueButtonJoinResponse, BigBlueButtonCreateAndJoinMeetingResponse, BigBlueButtonWidgetResponse } from "../../models/WidgetResponses";
import { AutoWired } from "typescript-ioc/es6";
import { ApiError } from "../ApiError";
import { sha256 } from "../../utils/hashing";
import config from "../../config";
import { parseStringPromise } from "xml2js";
import * as randomString from "random-string";
import { MatrixStickerBot } from "../../matrix/MatrixStickerBot";
/**
* API for the BigBlueButton widget.
@ -21,6 +26,9 @@ export class DimensionBigBlueButtonService {
private authenticityTokenRegexp = new RegExp(`name="authenticity_token" value="([^"]+)".*`);
// join handles the request from a client to join a BigBlueButton meeting
// via a Greenlight URL. Note that this is no longer the only way to join a
// BigBlueButton meeting. See xxx below for the API that bypasses Greenlight
// and instead calls the BigBlueButton API directly.
//
// The client is expected to send a link created by greenlight, the nice UI
// that's recommended to be installed on top of BBB, which is itself a BBB
@ -104,7 +112,6 @@ export class DimensionBigBlueButtonService {
@GET
@Path("join")
public async join(
joinRequest: BigBlueButtonJoinRequest,
@QueryParam("greenlightUrl") greenlightURL: string,
@QueryParam("fullName") fullName: string,
): Promise<BigBlueButtonJoinResponse|ApiError> {
@ -114,7 +121,6 @@ export class DimensionBigBlueButtonService {
LogService.info("BigBlueButton", "URL from client: " + greenlightURL);
LogService.info("BigBlueButton", "MeetingID: " + greenlightMeetingID);
LogService.info("BigBlueButton", "Name given from client: " + fullName);
LogService.info("BigBlueButton", joinRequest);
// Query the URL the user has given us
let response = await this.doRequest("GET", greenlightURL);
@ -163,6 +169,238 @@ export class DimensionBigBlueButtonService {
return {url: joinUrl};
}
/**
* Clients can call this endpoint in order to retrieve the contents of the widget room state.
* This endpoint will create a BigBlueButton meeting and place the returned ID and password in the room state.
* @param {string} roomId The ID of the room that the widget will live in.
*/
@GET
@Path("widget_state")
public async widget(
@QueryParam("roomId") roomId: string,
): Promise<BigBlueButtonWidgetResponse|ApiError> {
// Hash the room ID in order to generate a unique widget ID
const widgetId = sha256(roomId + "bigbluebutton");
const widgetName = config.bigbluebutton.widgetName;
const widgetTitle = config.bigbluebutton.widgetTitle;
const widgetAvatarUrl = config.bigbluebutton.widgetAvatarUrl;
LogService.info("BigBlueButton", "Got a meeting create request for room: " + roomId);
// NOTE: BBB meetings will by default end a minute or two after the last person leaves.
const createQueryParameters = {
meetingID: randomString(20),
// To help admins link meeting IDs to rooms
meta_MatrixRoomID: roomId,
};
// Create a new meeting.
const createResponse = await this.makeBBBApiCall("GET", "create", createQueryParameters, null);
LogService.info("BigBlueButton", createResponse);
// The password users will join with.
// TODO: We currently give users access to moderate the meeting by returning createResponse.moderatorPW instead
// of createResponse.attendeePW. The latter lets people join as viewers without any moderator permissions.
// Allowing this would likely require saving the moderator password for a meeting in Dimension and authenticating
// users by room power level when they call getJoinUrl. Unfortunately, doing would either require us to have a
// user in the room or have the user be a Synapse server admin (so that they can see room state).
const meetingPassword = createResponse.moderatorPW[0];
// Retrieve the user ID that dimension is running as
const widgetCreatorUserId = await MatrixStickerBot.getUserId();
// Add all necessary client variables to the url when loading the widget
const widgetUrl = config.dimension.publicUrl +
"/widgets/bigbluebutton" +
"?widgetId=$matrix_widget_id" +
"&roomId=$matrix_room_id" +
// Indicate that we would like to join a meeting created by Dimension, rather than doing so via
// a greenlight URL
"&createMeeting=true" +
"&displayName=$matrix_display_name" +
"&avatarUrl=$matrix_avatar_url" +
"&userId=$matrix_user_id" +
// Provide the meeting details in the state event
`&meetingId=${createResponse.meetingID[0]}` +
`&meetingPassword=${meetingPassword}` +
"&auth=$openidtoken-jwt";
return {
"widget_id": widgetId,
"widget": {
"creatorUserId": widgetCreatorUserId,
"id": widgetId,
"type": "m.custom",
"waitForIframeLoad": true,
"name": widgetName,
"avatar_url": widgetAvatarUrl,
"url": widgetUrl,
"data": {
"title": widgetTitle,
}
},
"layout": {
"container": "top",
"index": 0,
"width": 65,
"height": 50,
}
}
}
/**
* Clients can call this endpoint in order to retrieve a URL that leads to the BigBlueButton API that they can
* use to join the meeting with. They will need to provide the meeting ID and password which are only available
* from the widget room state event.
* @param {BigBlueButtonGetJoinUrlRequest} getJoinUrlRequest The body of the request.
*/
@POST
@Path("getJoinUrl")
public async getJoinUrl(
getJoinUrlRequest: BigBlueButtonGetJoinUrlRequest,
): Promise<BigBlueButtonCreateAndJoinMeetingResponse|ApiError> {
// Check if the meeting exists and is running. If not, return an error for each case
let getMeetingInfoParameters = {
meetingID: getJoinUrlRequest.meetingId,
}
const getMeetingInfoResponse = await this.makeBBBApiCall("GET", "getMeetingInfo", getMeetingInfoParameters, null);
LogService.info("BigBlueButton", getMeetingInfoResponse)
if (getMeetingInfoResponse.returncode[0] === "FAILED") {
// This meeting does not exist, inform the user
return new ApiError(
400,
{error: "This meeting does not exist."},
"UNKNOWN_MEETING_ID",
);
} else if (getMeetingInfoResponse.running[0] === "false" && getMeetingInfoResponse.endTime[0] !== "0") {
// This meeting did exist, but has ended. Inform the user
return new ApiError(
400,
{error: "This meeting has ended."},
"MEETING_HAS_ENDED",
);
}
// Construct a fullName parameter from the provided user ID and display name (if provided).
// It's important to display the user ID so that users cannot impersonate each other.
let fullName: string;
if (getJoinUrlRequest.displayName) {
fullName = `${getJoinUrlRequest.displayName} (${getJoinUrlRequest.userId})`;
} else {
fullName = getJoinUrlRequest.userId;
}
let joinQueryParameters = {
meetingID: getJoinUrlRequest.meetingId,
password: getJoinUrlRequest.meetingPassword,
fullName: fullName,
userID: getJoinUrlRequest.userId,
}
// Add an avatar to the join request if the user provided one
if (getJoinUrlRequest.avatarUrl.startsWith("mxc")) {
joinQueryParameters["avatarURL"] = this.getHTTPAvatarUrlFromMXCUrl(getJoinUrlRequest.avatarUrl);
}
// Calculate the checksum for the join URL. We need to do so as a browser would as we're passing this back to a browser
const checksum = this.bbbChecksumFromCallNameAndQueryParamaters("join", joinQueryParameters, true);
// Construct the join URL, which we'll give back to the client, who can then add additional parameters to (or we just do it)
const url = `${config.bigbluebutton.apiBaseUrl}/join?${this.queryStringFromObject(joinQueryParameters, true)}&checksum=${checksum}`;
return {
url: url,
};
}
/**
* Make an API call to the configured BBB server instance.
* @param {string} method The HTTP method to use for the request.
* @param {string} apiCallName The name of the API (the last bit of the endpoint) to call. e.g 'create', 'join'.
* @param {any} queryParameters The query parameters to use in the request.
* @param {any} body The body of the request.
* @returns {any} The response to the call.
*/
private async makeBBBApiCall(
method: string,
apiCallName: string,
queryParameters: any,
body: any,
): Promise<any> {
// Compute the checksum needed to authenticate the request (as derived from the configured shared secret)
queryParameters.checksum = this.bbbChecksumFromCallNameAndQueryParamaters(apiCallName, queryParameters, false);
// Get the URL host and path using the configured api base and the API call name
const url = `${config.bigbluebutton.apiBaseUrl}/${apiCallName}`;
// Now make the request!
const response = await this.doRequest(method, url, queryParameters, body);
// Parse and return the XML from the response
// TODO: XML parsing error handling
const parsedResponse = await parseStringPromise(response.body);
// Extract the "response" object
return parsedResponse.response;
}
/**
* Converts an object representing a query string into a checksum suitable for appending to a BBB API call.
* Docs: https://docs.bigbluebutton.org/dev/api.html#usage
* @param {string} apiCallName The name of the API to call, e.g "create", "join".
* @param {any} queryParameters An object representing a set of query parameters represented by keys and values.
* @param {boolean} encodeAsBrowser Whether to encode the query string as a browser would.
* @returns {string} The checksum for the request.
*/
private bbbChecksumFromCallNameAndQueryParamaters(apiCallName: string, queryParameters: any, encodeAsBrowser: boolean): string {
// Convert the query parameters object into a string
// We URL encode each value as a browser would. If we don't, our resulting checksum will not match.
const widgetQueryString = this.queryStringFromObject(queryParameters, encodeAsBrowser);
LogService.info("BigBlueButton", "Built widget string:" + widgetQueryString);
LogService.info("BigBlueButton", "Hashing:" + apiCallName + widgetQueryString + config.bigbluebutton.sharedSecret);
// Hash the api name and query parameters to get the checksum, and add it to the set of query parameters
return sha256(apiCallName + widgetQueryString + config.bigbluebutton.sharedSecret);
}
/**
* Converts an object containing keys and values as strings into a string representing URL query parameters.
* @param queryParameters
* @param encodeAsBrowser
* @returns {string} The query parameter object as a string.
*/
private queryStringFromObject(queryParameters: any, encodeAsBrowser: boolean): string {
return Object.keys(queryParameters).map(k => k + "=" + this.encodeForUrl(queryParameters[k], encodeAsBrowser)).join("&");
}
/**
* Encodes a string in the same fashion browsers do (encoding ! and other characters).
* @param {string} text The text to encode.
* @param {boolean} encodeAsBrowser Whether to encode the query string as a browser would.
* @returns {string} The encoded text.
*/
private encodeForUrl(text: string, encodeAsBrowser: boolean): string {
let encodedText = encodeURIComponent(text);
if (!encodeAsBrowser) {
// use + instead of %20 for space to match what the 'request' JavaScript library does do.
// encodeURIComponent doesn't escape !'()*, so manually escape them.
encodedText = encodedText.replace(/%20/g, '+').replace(/[!'()]/g, escape).replace(/\*/g, "%2A");
}
return encodedText;
}
/**
* Perform an HTTP request.
* @param {string} method The HTTP method to use.
* @param {string} url The URL (without query parameters) to request.
* @param {string} qs The query parameters to use with the request.
* @param {string} body The JSON body of the request
* @param {boolean} followRedirect Whether to follow redirect responses automatically.
*/
private async doRequest(
method: string,
url: string,
@ -180,6 +418,7 @@ export class DimensionBigBlueButtonService {
followRedirect: followRedirect,
jar: true, // remember cookies between requests
json: false, // expect html
}, (err, res, _body) => {
try {
if (err) {
@ -204,4 +443,13 @@ export class DimensionBigBlueButtonService {
});
}
private getHTTPAvatarUrlFromMXCUrl(mxc: string): string {
const width = 64;
const height = 64;
const method = "scale";
mxc = mxc.substring("mxc://".length).split('?')[0];
return `${config.dimension.publicUrl}/api/v1/dimension/media/thumbnail/${mxc}?width=${width}&height=${height}&method=${method}&animated=false`;
}
}

View file

@ -28,6 +28,13 @@ export interface DimensionConfig {
telegram: {
botToken: string;
};
bigbluebutton: {
apiBaseUrl: string;
sharedSecret: string;
widgetName: string;
widgetTitle: string;
widgetAvatarUrl: string;
};
stickers: {
enabled: boolean;
stickerBot: string;

View file

@ -1,7 +1,15 @@
export interface BigBlueButtonJoinRequest {
// A URL supplied by greenlight, BigBlueButton's nice UI project that is itself
// a BigBlueButton client
greenlightUrl: string;
// The name the user wishes to join the meeting with
fullName: string;
export interface BigBlueButtonGetJoinUrlRequest {
// The display name of the user attempting to join the meeting.
// Will be combined with userId and passed to BigBlueButton.
displayName: string;
// The user ID of the user attempting to join the meeting.
// Will be combined with displayName and passed to BigBlueButton.
userId: string;
// Optional. The avatar of the user attempting to join the meeting.
// Will be passed to BigBlueButton.
avatarUrl: string;
// The ID of the meeting to join.
meetingId: string;
// The password to join the meeting with.
meetingPassword: string;
}

View file

@ -2,3 +2,30 @@ export interface BigBlueButtonJoinResponse {
// The meeting URL the client should load to join the meeting
url: string;
}
export interface BigBlueButtonCreateAndJoinMeetingResponse {
// The meeting URL the client should load to join the meeting
url: string;
}
export interface BigBlueButtonWidgetResponse {
widget_id: string;
widget: {
creatorUserId: string;
id: string;
type: string;
waitForIframeLoad: boolean;
name: string;
avatar_url: string;
url: string;
data: {
title: string;
}
};
layout: {
container: string;
index: number;
width: number;
height: number;
};
}

View file

@ -2,4 +2,8 @@ import * as crypto from "crypto";
export function md5(text: string): string {
return crypto.createHash("md5").update(text).digest('hex').toLowerCase();
}
}
export function sha256(text: string): string {
return crypto.createHash("sha256").update(text).digest('hex').toLowerCase();
}

View file

@ -27,6 +27,7 @@ export class BigBlueButtonConfigComponent extends WidgetComponent {
protected OnNewWidgetPrepared(widget: EditableWidget): void {
widget.dimension.newData["conferenceUrl"] = this.bigBlueButtonWidget.options.conferenceUrl;
widget.dimension.newData["createMeeting"] = this.bigBlueButtonWidget.options.createMeeting;
}
protected OnWidgetBeforeAdd(widget: EditableWidget) {
@ -43,6 +44,7 @@ export class BigBlueButtonConfigComponent extends WidgetComponent {
let widgetQueryString = url.format({
query: {
"conferenceUrl": "$conferenceUrl",
"createMeeting": "$createMeeting",
"displayName": "$matrix_display_name",
"avatarUrl": "$matrix_avatar_url",
"userId": "$matrix_user_id",
@ -50,5 +52,6 @@ export class BigBlueButtonConfigComponent extends WidgetComponent {
});
widgetQueryString = this.decodeParams(widgetQueryString, Object.keys(widget.dimension.newData).map(k => "$" + k));
widget.dimension.newUrl = window.location.origin + "/widgets/bigbluebutton" + widgetQueryString;
console.log("URL ended up as:", widget.dimension.newUrl);
}
}

View file

@ -69,6 +69,11 @@ export interface FE_BigBlueButtonJoin {
url: string;
}
export interface FE_BigBlueButtonCreateAndJoinMeeting {
// The meeting URL the client should load to join the meeting
url: string;
}
export interface FE_StickerConfig {
enabled: boolean;
stickerBot: string;
@ -96,6 +101,7 @@ export interface FE_JitsiWidget extends FE_Widget {
export interface FE_BigBlueButtonWidget extends FE_Widget {
options: {
conferenceUrl: string;
createMeeting: boolean;
};
}

View file

@ -1,6 +1,6 @@
import { Injectable } from "@angular/core";
import { AuthedApi } from "../authed-api";
import { FE_BigBlueButtonJoin } from "../../models/integration"
import { FE_BigBlueButtonJoin, FE_BigBlueButtonCreateAndJoinMeeting } from "../../models/integration"
import { HttpClient } from "@angular/common/http";
import { ApiError } from "../../../../../src/api/ApiError";
@ -10,7 +10,15 @@ export class BigBlueButtonApiService extends AuthedApi {
super(http);
}
public joinMeeting(url: string, name: string): Promise<FE_BigBlueButtonJoin|ApiError> {
public joinMeetingWithGreenlightUrl(url: string, name: string): Promise<FE_BigBlueButtonJoin|ApiError> {
return this.authedGet<FE_BigBlueButtonJoin|ApiError>("/api/v1/dimension/bigbluebutton/join", {greenlightUrl: url, fullName: name}).toPromise();
}
public getJoinUrl(displayName: string, userId: string, avatarUrl: string, meetingId: string, meetingPassword: string): Promise<FE_BigBlueButtonCreateAndJoinMeeting|ApiError> {
return this.authedPost<FE_BigBlueButtonCreateAndJoinMeeting|ApiError>(
"/api/v1/dimension/bigbluebutton/getJoinUrl",
{displayName: displayName, userId: userId, avatarUrl: avatarUrl, meetingId: meetingId, meetingPassword: meetingPassword},
).toPromise();
}
}

View file

@ -1,12 +1,11 @@
import { Component, OnDestroy, OnInit } from "@angular/core";
import { ActivatedRoute } from "@angular/router";
import { WidgetApiService } from "../../shared/services/integrations/widget-api.service";
import { Subscription } from "rxjs/Subscription";
import { ScalarWidgetApi } from "../../shared/services/scalar/scalar-widget.api";
import { CapableWidget } from "../capable-widget";
import { DomSanitizer, SafeUrl } from "@angular/platform-browser";
import { BigBlueButtonApiService } from "../../shared/services/integrations/bigbluebutton-api.service";
import { FE_BigBlueButtonJoin } from "../../shared/models/integration";
import { FE_BigBlueButtonCreateAndJoinMeeting, FE_BigBlueButtonJoin } from "../../shared/models/integration";
import { TranslateService } from "@ngx-translate/core";
@Component({
@ -24,6 +23,28 @@ export class BigBlueButtonWidgetWrapperComponent extends CapableWidget implement
private conferenceUrl: string;
private displayName: string;
private userId: string;
private avatarUrl: string;
private meetingId: string;
private meetingPassword: string;
/**
*
* The name to join the BigBlueButton meeting with. Made up of metadata the client passes to us.
*/
private joinName: string;
/**
* Whether we expect the meeting to be created on command.
*
* True if we'd like the meeting to be created, false if we have a greenlight URL leading to an existing meeting
* and would like Dimension to translate that to a BigBlueButton meeting URL.
*/
private createMeeting: boolean;
/**
* The ID of the room, required if createMeeting is true.
*/
private roomId: string;
/**
* The poll period in ms while waiting for a meeting to start
@ -52,7 +73,6 @@ export class BigBlueButtonWidgetWrapperComponent extends CapableWidget implement
constructor(activatedRoute: ActivatedRoute,
private bigBlueButtonApi: BigBlueButtonApiService,
private widgetApi: WidgetApiService,
private sanitizer: DomSanitizer,
public translate: TranslateService) {
super();
@ -60,12 +80,22 @@ export class BigBlueButtonWidgetWrapperComponent extends CapableWidget implement
let params: any = activatedRoute.snapshot.queryParams;
console.log("BigBlueButton: Given greenlight url: " + params.conferenceUrl);
this.roomId = params.roomId;
this.createMeeting = params.createMeeting;
this.conferenceUrl = params.conferenceUrl;
this.displayName = params.displayName;
this.avatarUrl = params.avatarUrl;
this.meetingId = params.meetingId;
this.meetingPassword = params.meetingPassword;
this.userId = params.userId || params.email; // Element uses `email` when placing a conference call
// Create a nick to display in the meeting
this.joinName = `${this.displayName} (${this.userId})`;
console.log("BigBlueButton: should create meeting: " + this.createMeeting);
console.log("BigBlueButton: will join as: " + this.joinName);
console.log("BigBlueButton: got room ID: " + this.roomId);
// Set the widget ID if we have it
ScalarWidgetApi.widgetId = params.widgetId;
}
@ -101,12 +131,53 @@ export class BigBlueButtonWidgetWrapperComponent extends CapableWidget implement
this.statusMessage = "Joining conference...";
}
// Generate a nick to display in the meeting
const joinName = `${this.displayName} (${this.userId})`;
// Make a request to Dimension requesting the join URL
if (this.createMeeting) {
// Ask Dimension to return a URL for joining a meeting that it created
this.joinThroughDimension();
} else {
// Provide Dimension with a Greenlight URL, which it will transform into
// a BBB meeting URL
this.joinThroughGreenlightUrl();
}
}
// Ask Dimension to create a meeting (or use an existing one) for this room and return the embeddable meeting URL
private async joinThroughDimension() {
console.log("BigBlueButton: Joining meeting created by Dimension with meeting ID: " + this.meetingId);
this.bigBlueButtonApi.getJoinUrl(this.displayName, this.userId, this.avatarUrl, this.meetingId, this.meetingPassword).then((response) => {
console.log("The response");
console.log(response);
if ("errorCode" in response) {
// This is an instance of ApiError
if (response.errorCode === "UNKNOWN_MEETING_ID") {
// This meeting ID is invalid.
// Inform the user that they should try and start a new meeting
this.statusMessage = "This meeting has ended or otherwise does not exist.<br>Please start a new meeting.";
return;
}
if (response.errorCode === "MEETING_HAS_ENDED") {
// It's likely that everyone has left the meeting, and it's been garbage collected.
// Inform the user that they should try and start a new meeting
this.statusMessage = "This meeting has ended.<br>Please start a new meeting.";
return;
}
// Otherwise this is a generic error
this.statusMessage = "An error occurred while loading the meeting";
}
// Retrieve and embed the meeting URL
const joinUrl = (response as FE_BigBlueButtonCreateAndJoinMeeting).url;
this.embedMeetingWithUrl(joinUrl);
});
}
// Hand Dimension a Greenlight URL and receive a translated, embeddable meeting URL in response
private joinThroughGreenlightUrl() {
console.log("BigBlueButton: joining via greenlight url:", this.conferenceUrl);
this.bigBlueButtonApi.joinMeeting(this.conferenceUrl, joinName).then((response) => {
this.bigBlueButtonApi.joinMeetingWithGreenlightUrl(this.conferenceUrl, this.joinName).then((response) => {
if ("errorCode" in response) {
// This is an instance of ApiError
if (response.errorCode === "WAITING_FOR_MEETING_START") {
@ -122,32 +193,32 @@ export class BigBlueButtonWidgetWrapperComponent extends CapableWidget implement
this.statusMessage = "An error occurred while loading the meeting";
}
// Retrieve and embed the meeting URL
const joinUrl = (response as FE_BigBlueButtonJoin).url;
// Check if the given URL is embeddable
this.widgetApi.isEmbeddable(joinUrl).then(result => {
this.canEmbed = result.canEmbed;
this.statusMessage = null;
// Embed the return meeting URL, joining the meeting
this.embedUrl = this.sanitizer.bypassSecurityTrustResourceUrl(joinUrl);
// Inform the client that we would like the meeting to remain visible for its duration
ScalarWidgetApi.sendSetAlwaysOnScreen(true);
}).catch(err => {
console.error(err);
this.canEmbed = false;
this.statusMessage = "Unable to embed meeting";
});
this.embedMeetingWithUrl(joinUrl);
});
}
private embedMeetingWithUrl(url: string) {
// Hide widget-related UI
this.statusMessage = null;
// Embed the return meeting URL, joining the meeting
this.canEmbed = true;
this.embedUrl = this.sanitizer.bypassSecurityTrustResourceUrl(url);
// Inform the client that we would like the meeting to remain visible for its duration
ScalarWidgetApi.sendSetAlwaysOnScreen(true);
}
public ngOnDestroy() {
if (this.bigBlueButtonApiSubscription) this.bigBlueButtonApiSubscription.unsubscribe();
}
protected onCapabilitiesSent(): void {
super.onCapabilitiesSent();
// Don't set alwaysOnScreen until we start a meeting
ScalarWidgetApi.sendSetAlwaysOnScreen(false);
}

View file

@ -293,7 +293,7 @@
"There are currently no integrations which support encrypted rooms. Sorry about that!": "Derzeit gibt es keine Integrationen, die verschlüsselte Räume unterstützen. Das tut mir leid!",
"No integrations available": "Keine Integrationen verfügbar",
"This room does not have any compatible integrations. Please contact the server owner if you're seeing this message.": "Dieser Raum hat keine kompatiblen Integrationen. Bitte wenden Sie sich an den Server-Administrator, wenn diese Meldung angezeigt wird.",
"BigBlueButton Conference": "",
"BigBlueButton Conference": "BigBlueButton Konferenz",
"Join Conference": "An der Konferenz teilnehmen",
"Sorry, this content cannot be embedded": "Dieser Inhalt kann leider nicht eingebettet werden",
"Start camera:": "",

View file

@ -293,7 +293,7 @@
"There are currently no integrations which support encrypted rooms. Sorry about that!": "There are currently no integrations which support encrypted rooms. Sorry about that!",
"No integrations available": "No integrations available",
"This room does not have any compatible integrations. Please contact the server owner if you're seeing this message.": "This room does not have any compatible integrations. Please contact the server owner if you're seeing this message.",
"BigBlueButton Conference": "",
"BigBlueButton Conference": "BigBlueButton Conference",
"Join Conference": "Join Conference",
"Sorry, this content cannot be embedded": "Sorry, this content cannot be embedded",
"Start camera:": "",