integrations: Add support for BigBlueButton voice only calls.

We now allow users to create voice calls when their call provider is
BigBlueButton. This is done by creating a call where cameras are
disabled for all participants in the call -- a voice call, and making
only the call creator the moderator, so no one else can switch a voice
only call to a video call.

Also, we stop using the deprecated fields "attendeePW" and "moderatorPW"
in the Big Blue Button API, and use "role" instead.

The side effects are that now we only support BigBlueButton 2.4 and
above, and that only the call creator is a moderator and all other
joinees are viewers for all BigBlueButton calls.

Fixes: #26550.

Most of the code for the integration was written by Nehal.
Apoorva made the changes that resolve conflicts which were
introduced because of the `typed_endpoint` decorator.

With some documentation tweaks by tabbott.

Co-authored-by: Apoorva Pendse <apoorvavpendse@gmail.com>
This commit is contained in:
Nehal Sharma
2025-01-02 07:24:26 +05:30
committed by Tim Abbott
parent 696a0ac8ef
commit 04f06a4588
10 changed files with 129 additions and 50 deletions

View File

@@ -20,6 +20,13 @@ format used by the Zulip server that they are interacting with.
## Changes in Zulip 10.0
**Feature level 337**
* `POST /calls/bigbluebutton/create`: Added a `voice_only` parameter
controlling whether the call should be voice-only, in which case we
keep cameras disabled for this call. Now the call creator is a
moderator and all other joinees are viewers.
**Feature level 336**
* [Markdown message formatting](/api/message-formatting#image-previews): Added

View File

@@ -74,7 +74,7 @@ in the Zulip organizations where you want to use it.
To use the [BigBlueButton](https://bigbluebutton.org/) video call
integration on a self-hosted Zulip installation, you'll need to have a
BigBlueButton server and configure it:
BigBlueButton server (version 2.4+) and configure it:
1. Get the Shared Secret using the `bbb-conf --secret` command on your
BigBlueButton Server. See also [the BigBlueButton

View File

@@ -90,14 +90,10 @@ supported by Zulip are:
* [Zoom integration](/integrations/doc/zoom)
* [BigBlueButton integration](/integrations/doc/big-blue-button)
If you choose BigBlueButton as the call provider, there will be a single button
(<i class="zulip-icon zulip-icon-video-call"></i>) for starting a call in the
compose box. The call is initiated with cameras turned off.
!!! tip ""
It is also possible to disable the video and voice call buttons for your organization
by setting the provider to "None".
You can disable the video and voice call buttons for your organization
by setting the **call provider** to "None".
### Change your organization's call provider

View File

@@ -34,7 +34,7 @@ DESKTOP_WARNING_VERSION = "5.9.3"
# new level means in api_docs/changelog.md, as well as "**Changes**"
# entries in the endpoint's documentation in `zulip.yaml`.
API_FEATURE_LEVEL = 336 # Last bumped for data-original-content-type and data-transcoded-image
API_FEATURE_LEVEL = 337 # Last bumped for voice_only param addition for BigBlueButton
# Bump the minor PROVISION_VERSION to indicate that folks should provision
# only when going from an old version of the code to a newer version. Bump

View File

@@ -39,7 +39,9 @@ export function compute_show_audio_chat_button(): boolean {
get_jitsi_server_url() !== null &&
realm.realm_video_chat_provider === available_providers.jitsi_meet.id) ||
(available_providers.zoom &&
realm.realm_video_chat_provider === available_providers.zoom.id)
realm.realm_video_chat_provider === available_providers.zoom.id) ||
(available_providers.big_blue_button &&
realm.realm_video_chat_provider === available_providers.big_blue_button.id)
) {
return true;
}

View File

@@ -120,19 +120,21 @@ export function generate_and_insert_audio_or_video_call_link(
available_providers.big_blue_button &&
realm.realm_video_chat_provider === available_providers.big_blue_button.id
) {
if (is_audio_call) {
// TODO: Add support for audio-only BigBlueButton calls here.
return;
}
const meeting_name = get_recipient_label() + " meeting";
const request = {
meeting_name,
voice_only: is_audio_call,
};
void channel.get({
url: "/json/calls/bigbluebutton/create",
data: {
meeting_name,
},
data: request,
success(response) {
const data = call_response_schema.parse(response);
if (is_audio_call) {
insert_audio_call_url(data.url, $target_textarea);
} else {
insert_video_call_url(data.url, $target_textarea);
}
},
});
} else {

View File

@@ -217,7 +217,7 @@ test("videos", ({override}) => {
assert.match(syntax_to_insert, audio_link_regex);
})();
(function test_bbb_video_link_compose_clicked() {
(function test_bbb_audio_and_video_links_compose_clicked() {
let syntax_to_insert;
let called = false;
@@ -237,7 +237,6 @@ test("videos", ({override}) => {
called = true;
});
const handler = $("body").get_on_handler("click", ".video_link");
$("textarea#compose-textarea").val("");
override(
@@ -254,15 +253,28 @@ test("videos", ({override}) => {
options.success({
result: "success",
msg: "",
url: "/calls/bigbluebutton/join?meeting_id=%22zulip-1%22&password=%22AAAAAAAAAA%22&checksum=%2232702220bff2a22a44aee72e96cfdb4c4091752e%22",
url:
"/calls/bigbluebutton/join?meeting_id=%22zulip-1%22&moderator=%22AAAAAAAAAA%22&lock_settings_disable_cam=" +
options.data.voice_only +
"&checksum=%2232702220bff2a22a44aee72e96cfdb4c4091752e%22",
});
};
handler(ev);
$("textarea#compose-textarea").val("");
const video_handler = $("body").get_on_handler("click", ".video_link");
video_handler(ev);
const video_link_regex =
/\[translated: Join video call\.]\(\/calls\/bigbluebutton\/join\?meeting_id=%22zulip-1%22&password=%22AAAAAAAAAA%22&checksum=%2232702220bff2a22a44aee72e96cfdb4c4091752e%22\)/;
/\[translated: Join video call\.]\(\/calls\/bigbluebutton\/join\?meeting_id=%22zulip-1%22&moderator=%22AAAAAAAAAA%22&lock_settings_disable_cam=false&checksum=%2232702220bff2a22a44aee72e96cfdb4c4091752e%22\)/;
assert.ok(called);
assert.match(syntax_to_insert, video_link_regex);
const audio_handler = $("body").get_on_handler("click", ".audio_link");
audio_handler(ev);
const audio_link_regex =
/\[translated: Join voice call\.]\(\/calls\/bigbluebutton\/join\?meeting_id=%22zulip-1%22&moderator=%22AAAAAAAAAA%22&lock_settings_disable_cam=true&checksum=%2232702220bff2a22a44aee72e96cfdb4c4091752e%22\)/;
assert.ok(called);
assert.match(syntax_to_insert, audio_link_regex);
})();
});

View File

@@ -21864,8 +21864,14 @@ paths:
summary: Create BigBlueButton video call
description: |
Create a video call URL for a BigBlueButton video call.
Requires [BigBlueButton](/integrations/doc/big-blue-button)
Requires [BigBlueButton 2.4+](/integrations/doc/big-blue-button)
to be configured on the Zulip server.
The acting user will be given the moderator role on the call.
**Changes**: Prior to Zulip 10.0 (feature level 337), every
user was given the moderator role on BigBlueButton calls, via
encoding a moderator password in the generated URLs.
parameters:
- in: query
name: meeting_name
@@ -21875,6 +21881,18 @@ paths:
description: |
Meeting name for the BigBlueButton video call.
example: "test_channel meeting"
- in: query
name: voice_only
schema:
type: boolean
required: false
description: |
Configures whether the call is voice-only; if true,
disables cameras for all users. Only the call
creator/moderator can edit this configuration.
**Changes**: New in Zulip 10.0 (feature level 337).
example: true
responses:
"200":
description: Success.

View File

@@ -22,7 +22,17 @@ class TestVideoCall(ZulipTestCase):
{
"meeting_id": "a",
"name": "a",
"password": "a",
"lock_settings_disable_cam": True,
"moderator": self.user.id,
}
)
# For testing viewer role (different creator / moderator from self)
self.signed_bbb_a_object_different_creator = self.signer.sign_object(
{
"meeting_id": "a",
"name": "a",
"lock_settings_disable_cam": True,
"moderator": self.example_user("cordelia").id,
}
)
@@ -234,8 +244,9 @@ class TestVideoCall(ZulipTestCase):
mock.patch("zerver.views.video_calls.random.randint", return_value="1"),
mock.patch("secrets.token_bytes", return_value=b"\x00" * 20),
):
with mock.patch("zerver.views.video_calls.random.randint", return_value="1"):
response = self.client_get(
"/json/calls/bigbluebutton/create?meeting_name=general > meeting"
"/json/calls/bigbluebutton/create?meeting_name=general > meeting&voice_only=false"
)
response_dict = self.assert_json_success(response)
self.assertEqual(
@@ -247,7 +258,29 @@ class TestVideoCall(ZulipTestCase):
{
"meeting_id": "zulip-1",
"name": "general > meeting",
"password": "A" * 32,
"lock_settings_disable_cam": False,
"moderator": self.user.id,
}
),
),
)
# Testing for audio call
response = self.client_get(
"/json/calls/bigbluebutton/create?meeting_name=general > meeting&voice_only=true"
)
response_dict = self.assert_json_success(response)
self.assertEqual(
response_dict["url"],
append_url_query_string(
"/calls/bigbluebutton/join",
"bigbluebutton="
+ self.signer.sign_object(
{
"meeting_id": "zulip-1",
"name": "general > meeting",
"lock_settings_disable_cam": True,
"moderator": self.user.id,
}
),
),
@@ -257,8 +290,8 @@ class TestVideoCall(ZulipTestCase):
def test_join_bigbluebutton_redirect(self) -> None:
responses.add(
responses.GET,
"https://bbb.example.com/bigbluebutton/api/create?meetingID=a&name=a"
"&moderatorPW=a&attendeePW=a&checksum=131bdec35f62fc63d5436e6f791d6d7aed7cf79ef256c03597e51d320d042823",
"https://bbb.example.com/bigbluebutton/api/create?meetingID=a&name=a&lockSettingsDisableCam=True"
"&checksum=33349e6374ca9b2d15a0c6e51a42bc3e8f770de13f88660815c6449859856e20",
"<response><returncode>SUCCESS</returncode><messageKey/><createTime>0</createTime></response>",
)
response = self.client_get(
@@ -269,15 +302,27 @@ class TestVideoCall(ZulipTestCase):
self.assertEqual(
response["Location"],
"https://bbb.example.com/bigbluebutton/api/join?meetingID=a&"
"password=a&fullName=King%20Hamlet&createTime=0&checksum=47ca959b4ff5c8047a5a56d6e99c07e17eac43dbf792afc0a2a9f6491ec0048b",
"role=MODERATOR&fullName=King%20Hamlet&createTime=0&checksum=54259b884a7c20ddcd7b280a1b62e59d7990568fe4f22001812bc4bcfd161a46",
)
# Testing for viewer role
response = self.client_get(
"/calls/bigbluebutton/join",
{"bigbluebutton": self.signed_bbb_a_object_different_creator},
)
self.assertEqual(response.status_code, 302)
self.assertEqual(isinstance(response, HttpResponseRedirect), True)
self.assertEqual(
response["Location"],
"https://bbb.example.com/bigbluebutton/api/join?meetingID=a&"
"role=VIEWER&fullName=King%20Hamlet&createTime=0&checksum=52efaf64109ca4ec5a20a1d295f315af53f9e6ec30b50ed3707fd2909ac6bd94",
)
@responses.activate
def test_join_bigbluebutton_invalid_signature(self) -> None:
responses.add(
responses.GET,
"https://bbb.example.com/bigbluebutton/api/create?meetingID=a&name=a"
"&moderatorPW=a&attendeePW=a&checksum=131bdec35f62fc63d5436e6f791d6d7aed7cf79ef256c03597e51d320d042823",
"https://bbb.example.com/bigbluebutton/api/create?meetingID=a&name=a&lockSettingsDisableCam=True"
"&checksum=33349e6374ca9b2d15a0c6e51a42bc3e8f770de13f88660815c6449859856e20",
"<response><returncode>SUCCESS</returncode><messageKey/><createTime>0</createTime></response>",
)
response = self.client_get(
@@ -289,7 +334,7 @@ class TestVideoCall(ZulipTestCase):
def test_join_bigbluebutton_redirect_wrong_big_blue_button_checksum(self) -> None:
responses.add(
responses.GET,
"https://bbb.example.com/bigbluebutton/api/create?meetingID=a&name=a&moderatorPW=a&attendeePW=a&checksum=131bdec35f62fc63d5436e6f791d6d7aed7cf79ef256c03597e51d320d042823",
"https://bbb.example.com/bigbluebutton/api/create?meetingID=a&name=a&lockSettingsDisableCam=True&checksum=33349e6374ca9b2d15a0c6e51a42bc3e8f770de13f88660815c6449859856e20",
"<response><returncode>FAILED</returncode><messageKey>checksumError</messageKey>"
"<message>You did not pass the checksum security check</message></response>",
)
@@ -304,7 +349,7 @@ class TestVideoCall(ZulipTestCase):
# Simulate bbb server error
responses.add(
responses.GET,
"https://bbb.example.com/bigbluebutton/api/create?meetingID=a&name=a&moderatorPW=a&attendeePW=a&checksum=131bdec35f62fc63d5436e6f791d6d7aed7cf79ef256c03597e51d320d042823",
"https://bbb.example.com/bigbluebutton/api/create?meetingID=a&name=a&lockSettingsDisableCam=True&checksum=33349e6374ca9b2d15a0c6e51a42bc3e8f770de13f88660815c6449859856e20",
"",
status=500,
)
@@ -319,7 +364,7 @@ class TestVideoCall(ZulipTestCase):
# Simulate bbb server error
responses.add(
responses.GET,
"https://bbb.example.com/bigbluebutton/api/create?meetingID=a&name=a&moderatorPW=a&attendeePW=a&checksum=131bdec35f62fc63d5436e6f791d6d7aed7cf79ef256c03597e51d320d042823",
"https://bbb.example.com/bigbluebutton/api/create?meetingID=a&name=a&lockSettingsDisableCam=True&checksum=33349e6374ca9b2d15a0c6e51a42bc3e8f770de13f88660815c6449859856e20",
"<response><returncode>FAILURE</returncode><messageKey>otherFailure</messageKey></response>",
)
response = self.client_get(

View File

@@ -1,8 +1,6 @@
import hashlib
import json
import random
import secrets
from base64 import b32encode
from urllib.parse import quote, urlencode, urljoin
import requests
@@ -206,12 +204,15 @@ def deauthorize_zoom_user(request: HttpRequest) -> HttpResponse:
@typed_endpoint
def get_bigbluebutton_url(
request: HttpRequest, user_profile: UserProfile, *, meeting_name: str
request: HttpRequest,
user_profile: UserProfile,
*,
meeting_name: str,
voice_only: Json[bool] = False,
) -> HttpResponse:
# https://docs.bigbluebutton.org/dev/api.html#create for reference on the API calls
# https://docs.bigbluebutton.org/dev/api.html#usage for reference for checksum
id = "zulip-" + str(random.randint(100000000000, 999999999999))
password = b32encode(secrets.token_bytes(20)).decode() # 20 bytes means 32 characters
# We sign our data here to ensure a Zulip user cannot tamper with
# the join link to gain access to other meetings that are on the
@@ -220,7 +221,8 @@ def get_bigbluebutton_url(
{
"meeting_id": id,
"name": meeting_name,
"password": password,
"lock_settings_disable_cam": voice_only,
"moderator": request.user.id,
}
)
url = append_url_query_string("/calls/bigbluebutton/join", "bigbluebutton=" + signed)
@@ -250,14 +252,7 @@ def join_bigbluebutton(request: HttpRequest, *, bigbluebutton: str) -> HttpRespo
{
"meetingID": bigbluebutton_data["meeting_id"],
"name": bigbluebutton_data["name"],
"moderatorPW": bigbluebutton_data["password"],
# We generate the attendee password from moderatorPW,
# because the BigBlueButton API requires a separate
# password. This integration is designed to have all users
# join as moderators, so we generate attendeePW by
# truncating the moderatorPW while keeping it long enough
# to not be vulnerable to brute force attacks.
"attendeePW": bigbluebutton_data["password"][:16],
"lockSettingsDisableCam": bigbluebutton_data["lock_settings_disable_cam"],
},
quote_via=quote,
)
@@ -286,9 +281,11 @@ def join_bigbluebutton(request: HttpRequest, *, bigbluebutton: str) -> HttpRespo
join_params = urlencode(
{
"meetingID": bigbluebutton_data["meeting_id"],
# We use the moderator password here to grant ever user
# full moderator permissions to the bigbluebutton session.
"password": bigbluebutton_data["password"],
# We use the moderator role only for the user who created the
# meeting, the attendee role for everyone else, so that only
# the user who created the meeting can convert a voice-only
# call to a video call.
"role": "MODERATOR" if bigbluebutton_data["moderator"] == request.user.id else "VIEWER",
"fullName": request.user.full_name,
# https://docs.bigbluebutton.org/dev/api.html#create
# The createTime option is used to have the user redirected to a link