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 ## 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** **Feature level 336**
* [Markdown message formatting](/api/message-formatting#image-previews): Added * [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 To use the [BigBlueButton](https://bigbluebutton.org/) video call
integration on a self-hosted Zulip installation, you'll need to have a 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 1. Get the Shared Secret using the `bbb-conf --secret` command on your
BigBlueButton Server. See also [the BigBlueButton BigBlueButton Server. See also [the BigBlueButton

View File

@@ -90,14 +90,10 @@ supported by Zulip are:
* [Zoom integration](/integrations/doc/zoom) * [Zoom integration](/integrations/doc/zoom)
* [BigBlueButton integration](/integrations/doc/big-blue-button) * [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 "" !!! tip ""
It is also possible to disable the video and voice call buttons for your organization You can disable the video and voice call buttons for your organization
by setting the provider to "None". by setting the **call provider** to "None".
### Change your organization's call provider ### 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**" # new level means in api_docs/changelog.md, as well as "**Changes**"
# entries in the endpoint's documentation in `zulip.yaml`. # 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 # 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 # 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 && get_jitsi_server_url() !== null &&
realm.realm_video_chat_provider === available_providers.jitsi_meet.id) || realm.realm_video_chat_provider === available_providers.jitsi_meet.id) ||
(available_providers.zoom && (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; return true;
} }

View File

@@ -120,19 +120,21 @@ export function generate_and_insert_audio_or_video_call_link(
available_providers.big_blue_button && available_providers.big_blue_button &&
realm.realm_video_chat_provider === available_providers.big_blue_button.id 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 meeting_name = get_recipient_label() + " meeting";
const request = {
meeting_name,
voice_only: is_audio_call,
};
void channel.get({ void channel.get({
url: "/json/calls/bigbluebutton/create", url: "/json/calls/bigbluebutton/create",
data: { data: request,
meeting_name,
},
success(response) { success(response) {
const data = call_response_schema.parse(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); insert_video_call_url(data.url, $target_textarea);
}
}, },
}); });
} else { } else {

View File

@@ -217,7 +217,7 @@ test("videos", ({override}) => {
assert.match(syntax_to_insert, audio_link_regex); 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 syntax_to_insert;
let called = false; let called = false;
@@ -237,7 +237,6 @@ test("videos", ({override}) => {
called = true; called = true;
}); });
const handler = $("body").get_on_handler("click", ".video_link");
$("textarea#compose-textarea").val(""); $("textarea#compose-textarea").val("");
override( override(
@@ -254,15 +253,28 @@ test("videos", ({override}) => {
options.success({ options.success({
result: "success", result: "success",
msg: "", 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 = 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.ok(called);
assert.match(syntax_to_insert, video_link_regex); 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 summary: Create BigBlueButton video call
description: | description: |
Create a video call URL for a BigBlueButton video call. 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. 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: parameters:
- in: query - in: query
name: meeting_name name: meeting_name
@@ -21875,6 +21881,18 @@ paths:
description: | description: |
Meeting name for the BigBlueButton video call. Meeting name for the BigBlueButton video call.
example: "test_channel meeting" 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: responses:
"200": "200":
description: Success. description: Success.

View File

@@ -22,7 +22,17 @@ class TestVideoCall(ZulipTestCase):
{ {
"meeting_id": "a", "meeting_id": "a",
"name": "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("zerver.views.video_calls.random.randint", return_value="1"),
mock.patch("secrets.token_bytes", return_value=b"\x00" * 20), 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( 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) response_dict = self.assert_json_success(response)
self.assertEqual( self.assertEqual(
@@ -247,7 +258,29 @@ class TestVideoCall(ZulipTestCase):
{ {
"meeting_id": "zulip-1", "meeting_id": "zulip-1",
"name": "general > meeting", "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: def test_join_bigbluebutton_redirect(self) -> None:
responses.add( responses.add(
responses.GET, responses.GET,
"https://bbb.example.com/bigbluebutton/api/create?meetingID=a&name=a" "https://bbb.example.com/bigbluebutton/api/create?meetingID=a&name=a&lockSettingsDisableCam=True"
"&moderatorPW=a&attendeePW=a&checksum=131bdec35f62fc63d5436e6f791d6d7aed7cf79ef256c03597e51d320d042823", "&checksum=33349e6374ca9b2d15a0c6e51a42bc3e8f770de13f88660815c6449859856e20",
"<response><returncode>SUCCESS</returncode><messageKey/><createTime>0</createTime></response>", "<response><returncode>SUCCESS</returncode><messageKey/><createTime>0</createTime></response>",
) )
response = self.client_get( response = self.client_get(
@@ -269,15 +302,27 @@ class TestVideoCall(ZulipTestCase):
self.assertEqual( self.assertEqual(
response["Location"], response["Location"],
"https://bbb.example.com/bigbluebutton/api/join?meetingID=a&" "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 @responses.activate
def test_join_bigbluebutton_invalid_signature(self) -> None: def test_join_bigbluebutton_invalid_signature(self) -> None:
responses.add( responses.add(
responses.GET, responses.GET,
"https://bbb.example.com/bigbluebutton/api/create?meetingID=a&name=a" "https://bbb.example.com/bigbluebutton/api/create?meetingID=a&name=a&lockSettingsDisableCam=True"
"&moderatorPW=a&attendeePW=a&checksum=131bdec35f62fc63d5436e6f791d6d7aed7cf79ef256c03597e51d320d042823", "&checksum=33349e6374ca9b2d15a0c6e51a42bc3e8f770de13f88660815c6449859856e20",
"<response><returncode>SUCCESS</returncode><messageKey/><createTime>0</createTime></response>", "<response><returncode>SUCCESS</returncode><messageKey/><createTime>0</createTime></response>",
) )
response = self.client_get( response = self.client_get(
@@ -289,7 +334,7 @@ class TestVideoCall(ZulipTestCase):
def test_join_bigbluebutton_redirect_wrong_big_blue_button_checksum(self) -> None: def test_join_bigbluebutton_redirect_wrong_big_blue_button_checksum(self) -> None:
responses.add( responses.add(
responses.GET, 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>" "<response><returncode>FAILED</returncode><messageKey>checksumError</messageKey>"
"<message>You did not pass the checksum security check</message></response>", "<message>You did not pass the checksum security check</message></response>",
) )
@@ -304,7 +349,7 @@ class TestVideoCall(ZulipTestCase):
# Simulate bbb server error # Simulate bbb server error
responses.add( responses.add(
responses.GET, 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, status=500,
) )
@@ -319,7 +364,7 @@ class TestVideoCall(ZulipTestCase):
# Simulate bbb server error # Simulate bbb server error
responses.add( responses.add(
responses.GET, 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><returncode>FAILURE</returncode><messageKey>otherFailure</messageKey></response>",
) )
response = self.client_get( response = self.client_get(

View File

@@ -1,8 +1,6 @@
import hashlib import hashlib
import json import json
import random import random
import secrets
from base64 import b32encode
from urllib.parse import quote, urlencode, urljoin from urllib.parse import quote, urlencode, urljoin
import requests import requests
@@ -206,12 +204,15 @@ def deauthorize_zoom_user(request: HttpRequest) -> HttpResponse:
@typed_endpoint @typed_endpoint
def get_bigbluebutton_url( 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: ) -> HttpResponse:
# https://docs.bigbluebutton.org/dev/api.html#create for reference on the API calls # 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 # https://docs.bigbluebutton.org/dev/api.html#usage for reference for checksum
id = "zulip-" + str(random.randint(100000000000, 999999999999)) 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 # 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 # the join link to gain access to other meetings that are on the
@@ -220,7 +221,8 @@ def get_bigbluebutton_url(
{ {
"meeting_id": id, "meeting_id": id,
"name": meeting_name, "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) 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"], "meetingID": bigbluebutton_data["meeting_id"],
"name": bigbluebutton_data["name"], "name": bigbluebutton_data["name"],
"moderatorPW": bigbluebutton_data["password"], "lockSettingsDisableCam": bigbluebutton_data["lock_settings_disable_cam"],
# 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],
}, },
quote_via=quote, quote_via=quote,
) )
@@ -286,9 +281,11 @@ def join_bigbluebutton(request: HttpRequest, *, bigbluebutton: str) -> HttpRespo
join_params = urlencode( join_params = urlencode(
{ {
"meetingID": bigbluebutton_data["meeting_id"], "meetingID": bigbluebutton_data["meeting_id"],
# We use the moderator password here to grant ever user # We use the moderator role only for the user who created the
# full moderator permissions to the bigbluebutton session. # meeting, the attendee role for everyone else, so that only
"password": bigbluebutton_data["password"], # 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, "fullName": request.user.full_name,
# https://docs.bigbluebutton.org/dev/api.html#create # https://docs.bigbluebutton.org/dev/api.html#create
# The createTime option is used to have the user redirected to a link # The createTime option is used to have the user redirected to a link