diff --git a/api_docs/changelog.md b/api_docs/changelog.md
index 47032dcec2..306834270c 100644
--- a/api_docs/changelog.md
+++ b/api_docs/changelog.md
@@ -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
diff --git a/docs/production/video-calls.md b/docs/production/video-calls.md
index cb5e5cbbf9..22081924cc 100644
--- a/docs/production/video-calls.md
+++ b/docs/production/video-calls.md
@@ -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
diff --git a/help/start-a-call.md b/help/start-a-call.md
index 0e5894532c..98754c23b0 100644
--- a/help/start-a-call.md
+++ b/help/start-a-call.md
@@ -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
-() 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
diff --git a/version.py b/version.py
index be7a19b60d..dcdd3bb158 100644
--- a/version.py
+++ b/version.py
@@ -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
diff --git a/web/src/compose_call.ts b/web/src/compose_call.ts
index b8428a1e67..1a968a6a2b 100644
--- a/web/src/compose_call.ts
+++ b/web/src/compose_call.ts
@@ -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;
}
diff --git a/web/src/compose_call_ui.ts b/web/src/compose_call_ui.ts
index 962eae3584..b3f18351a0 100644
--- a/web/src/compose_call_ui.ts
+++ b/web/src/compose_call_ui.ts
@@ -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);
- insert_video_call_url(data.url, $target_textarea);
+ if (is_audio_call) {
+ insert_audio_call_url(data.url, $target_textarea);
+ } else {
+ insert_video_call_url(data.url, $target_textarea);
+ }
},
});
} else {
diff --git a/web/tests/compose_video.test.cjs b/web/tests/compose_video.test.cjs
index bb951ce4ee..4a0458c0de 100644
--- a/web/tests/compose_video.test.cjs
+++ b/web/tests/compose_video.test.cjs
@@ -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);
})();
});
diff --git a/zerver/openapi/zulip.yaml b/zerver/openapi/zulip.yaml
index 855b3dd2cc..51b483826a 100644
--- a/zerver/openapi/zulip.yaml
+++ b/zerver/openapi/zulip.yaml
@@ -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.
diff --git a/zerver/tests/test_create_video_call.py b/zerver/tests/test_create_video_call.py
index a592e9819b..4c9059d020 100644
--- a/zerver/tests/test_create_video_call.py
+++ b/zerver/tests/test_create_video_call.py
@@ -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,30 @@ 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&voice_only=false"
+ )
+ 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": False,
+ "moderator": self.user.id,
+ }
+ ),
+ ),
+ )
+
+ # Testing for audio call
response = self.client_get(
- "/json/calls/bigbluebutton/create?meeting_name=general > meeting"
+ "/json/calls/bigbluebutton/create?meeting_name=general > meeting&voice_only=true"
)
response_dict = self.assert_json_success(response)
self.assertEqual(
@@ -247,7 +279,8 @@ class TestVideoCall(ZulipTestCase):
{
"meeting_id": "zulip-1",
"name": "general > meeting",
- "password": "A" * 32,
+ "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",
"SUCCESS0",
)
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",
"SUCCESS0",
)
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",
"FAILEDchecksumError"
"You did not pass the checksum security check",
)
@@ -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",
"FAILUREotherFailure",
)
response = self.client_get(
diff --git a/zerver/views/video_calls.py b/zerver/views/video_calls.py
index 07799819a8..3697e00f82 100644
--- a/zerver/views/video_calls.py
+++ b/zerver/views/video_calls.py
@@ -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