mirror of
https://github.com/zulip/zulip.git
synced 2025-10-24 08:33:43 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
})();
|
||||
});
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user