diff --git a/docs/overview/changelog.md b/docs/overview/changelog.md
index 68113475da..c150c10269 100644
--- a/docs/overview/changelog.md
+++ b/docs/overview/changelog.md
@@ -64,6 +64,7 @@ in bursts.
- Added server support for creating an account from mobile/terminal apps.
- The Zulip desktop apps now do social authentication (Google, GitHub,
etc.) via an external browser.
+- Added support for BigBlueButton as video chat provider.
- Added support for setting an organization-wide default language for
code blocks.
- Added an API endpoint for fetching a single user.
diff --git a/frontend_tests/node_tests/compose.js b/frontend_tests/node_tests/compose.js
index ec68a9138c..5470c87dc0 100644
--- a/frontend_tests/node_tests/compose.js
+++ b/frontend_tests/node_tests/compose.js
@@ -947,6 +947,10 @@ run_test('initialize', () => {
id: 3,
name: "Zoom",
},
+ big_blue_button: {
+ id: 4,
+ name: "Big Blue Button",
+ },
};
page_params.realm_video_chat_provider =
@@ -1463,6 +1467,18 @@ run_test('on_events', () => {
video_link_regex = /\[Click to join video call\]\(example\.zoom\.com\)/;
assert(video_link_regex.test(syntax_to_insert));
+ page_params.realm_video_chat_provider =
+ page_params.realm_available_video_chat_providers.big_blue_button.id;
+
+ channel.get = function (options) {
+ assert(options.url === '/json/calls/bigbluebutton/create');
+ options.success({ url: '/calls/bigbluebutton/join?meeting_id=%22zulip-1%22&password=%22AAAAAAAAAA%22&checksum=%2232702220bff2a22a44aee72e96cfdb4c4091752e%22' });
+ };
+
+ handler(ev);
+ video_link_regex = /\[Click to join video call\]\(\/calls\/bigbluebutton\/join\?meeting_id=%22zulip-1%22&password=%22AAAAAAAAAA%22&checksum=%2232702220bff2a22a44aee72e96cfdb4c4091752e%22\)/;
+ assert(video_link_regex.test(syntax_to_insert));
+
}());
(function test_markdown_preview_compose_clicked() {
diff --git a/frontend_tests/node_tests/settings_org.js b/frontend_tests/node_tests/settings_org.js
index d643585616..84d5c2f9f7 100644
--- a/frontend_tests/node_tests/settings_org.js
+++ b/frontend_tests/node_tests/settings_org.js
@@ -759,6 +759,10 @@ run_test('set_up', () => {
id: 3,
name: "Zoom",
},
+ big_blue_button: {
+ id: 4,
+ name: "Big Blue Button",
+ },
};
simulate_tip_box();
diff --git a/static/js/compose.js b/static/js/compose.js
index 68987295ac..6352cb3c2c 100644
--- a/static/js/compose.js
+++ b/static/js/compose.js
@@ -1122,6 +1122,15 @@ exports.initialize = function () {
"width=800,height=500,noopener,noreferrer"
);
}
+ } else if (
+ page_params.realm_video_chat_provider === available_providers.big_blue_button.id) {
+
+ channel.get({
+ url: '/json/calls/bigbluebutton/create',
+ success: function (response) {
+ insert_video_call_url(response.url, target_textarea);
+ },
+ });
} else {
video_call_link = page_params.jitsi_server_url + "/" + video_call_id;
insert_video_call_url(video_call_link, target_textarea);
diff --git a/templates/zerver/features.html b/templates/zerver/features.html
index 6d77716b74..94157db3d8 100644
--- a/templates/zerver/features.html
+++ b/templates/zerver/features.html
@@ -238,7 +238,7 @@
VIDEO CALLS
Create and join video calls with a single click. Powered
- by your choice of Jitsi Meet or Zoom.
+ by your choice of Zoom, Jitsi Meet or Big Blue Button.
diff --git a/templates/zerver/help/start-a-call.md b/templates/zerver/help/start-a-call.md
index 3441c1a34a..564bc81e7b 100644
--- a/templates/zerver/help/start-a-call.md
+++ b/templates/zerver/help/start-a-call.md
@@ -37,6 +37,12 @@ Meet](https://meet.jit.si/).
You can also use Zulip with Jitsi Meet on-premise; to configure this,
just set `JITSI_SERVER_URL` in `/etc/zulip/settings.py`.
+{tab|bigbluebutton}
+
+In order to use Big Blue Button as the video call provider, you need
+to first configure the `BIG_BLUE_BUTTON_URL` setting in
+`/etc/zulip/settings.py`.
+
{tab|zoom}
Zulip supports Zoom as the video chat provider using an OAuth
diff --git a/tools/lib/capitalization.py b/tools/lib/capitalization.py
index 2e1fbc0452..881eb2d482 100644
--- a/tools/lib/capitalization.py
+++ b/tools/lib/capitalization.py
@@ -66,6 +66,7 @@ IGNORED_PHRASES = [
r"Emoji One",
r"mailinator.com",
r"HQ",
+ r"Big Blue Button",
# Code things
r".zuliprc",
r"__\w+\.\w+__",
diff --git a/zerver/lib/bugdown/tabbed_sections.py b/zerver/lib/bugdown/tabbed_sections.py
index 86a488b2ec..d7bfb5dd20 100644
--- a/zerver/lib/bugdown/tabbed_sections.py
+++ b/zerver/lib/bugdown/tabbed_sections.py
@@ -67,6 +67,7 @@ TAB_DISPLAY_NAMES = {
'zoom': 'Zoom (experimental)',
'jitsi-meet': 'Jitsi Meet',
+ 'bigbluebutton': 'Big Blue Button',
'disable': 'Disabled',
'chrome': 'Chrome',
diff --git a/zerver/models.py b/zerver/models.py
index 2898184b47..ef08bc24f3 100644
--- a/zerver/models.py
+++ b/zerver/models.py
@@ -362,11 +362,19 @@ class Realm(models.Model):
# ID 2 was used for the now-deleted Google Hangouts.
# ID 3 reserved for optional Zoom, see below.
}
+
if settings.VIDEO_ZOOM_CLIENT_ID is not None and settings.VIDEO_ZOOM_CLIENT_SECRET is not None:
VIDEO_CHAT_PROVIDERS['zoom'] = {
'name': "Zoom",
'id': 3,
}
+
+ if settings.BIG_BLUE_BUTTON_SECRET is not None and settings.BIG_BLUE_BUTTON_URL is not None:
+ VIDEO_CHAT_PROVIDERS['big_blue_button'] = {
+ 'name': "Big Blue Button",
+ 'id': 4
+ }
+
video_chat_provider = models.PositiveSmallIntegerField(default=VIDEO_CHAT_PROVIDERS['jitsi_meet']['id'])
default_code_block_language: Optional[str] = models.TextField(null=True, default=None)
diff --git a/zerver/openapi/openapi.py b/zerver/openapi/openapi.py
index 7d6ded4ab9..56e196f61c 100644
--- a/zerver/openapi/openapi.py
+++ b/zerver/openapi/openapi.py
@@ -88,6 +88,11 @@ EXCLUDE_PROPERTIES = {
'200': ['deliver_at'],
}
},
+ '/calls/bigbluebutton/create': {
+ 'get': {
+ '200': ['url']
+ }
+ },
}
# A list of endpoint-methods such that the endpoint
diff --git a/zerver/openapi/zulip.yaml b/zerver/openapi/zulip.yaml
index ab3323dbe7..6f60039199 100644
--- a/zerver/openapi/zulip.yaml
+++ b/zerver/openapi/zulip.yaml
@@ -4495,6 +4495,27 @@ paths:
"bot_email": "outgoing-bot@localhost"
}
+ /calls/bigbluebutton/create:
+ get:
+ tags: ["streams"]
+ operationId: create_big_blue_button_video_call
+ description: |
+ Create a video call url for a Big Blue Button video call.
+ Requires Big Blue Button to be configured on the Zulip server.
+ responses:
+ '200':
+ description: Success.
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/JsonSuccess'
+ example:
+ {
+ "msg": "",
+ "result": "success",
+ "url": "/calls/bbb/join?meeting_id=%22zulip-something%22&password=%22something%22&checksum=%22somechecksum%22"
+ }
+
components:
#######################
# Security definitions
diff --git a/zerver/tests/test_create_video_call.py b/zerver/tests/test_create_video_call.py
index e393b1da2d..28c0ef8f07 100644
--- a/zerver/tests/test_create_video_call.py
+++ b/zerver/tests/test_create_video_call.py
@@ -1,4 +1,7 @@
+from unittest import mock
+
import responses
+from django.http import HttpResponseRedirect
from zerver.lib.test_classes import ZulipTestCase
@@ -157,3 +160,57 @@ class TestVideoCall(ZulipTestCase):
content_type="application/json",
)
self.assert_json_success(response)
+
+ def test_create_bigbluebutton_link(self) -> None:
+ with mock.patch('zerver.views.video_calls.random.randint', return_value="1"), mock.patch(
+ 'zerver.views.video_calls.random.SystemRandom.choice', return_value="A"):
+ response = self.client_get("/json/calls/bigbluebutton/create")
+ self.assert_json_success(response)
+ self.assertEqual(response.json()['url'],
+ "/calls/bigbluebutton/join?meeting_id=%22zulip-1%22&password=%22AAAAAAAAAA%22"
+ "&checksum=%22697939301a52d3a2f0b3c3338895c1a5ab528933%22")
+
+ @responses.activate
+ def test_join_bigbluebutton_redirect(self) -> None:
+ responses.add(responses.GET, "https://bbb.example.com/bigbluebutton/api/create?meetingID=zulip-1&moderatorPW=a&attendeePW=aa&checksum=check",
+ "SUCCESS")
+ response = self.client_get("/calls/bigbluebutton/join?meeting_id=%22zulip-1%22&password=%22a%22&checksum=%22check%22")
+ self.assertEqual(response.status_code, 302)
+ self.assertEqual(isinstance(response, HttpResponseRedirect), True)
+ self.assertEqual(response.url, "https://bbb.example.com/bigbluebutton/api/join?meetingID=zulip-1&password=a"
+ "&fullName=King%20Hamlet&checksum=7ddbb4e7e5aa57cb8c58db12003f3b5b040ff530")
+
+ @responses.activate
+ def test_join_bigbluebutton_redirect_wrong_check(self) -> None:
+ responses.add(responses.GET,
+ "https://bbb.example.com/bigbluebutton/api/create?meetingID=zulip-1&moderatorPW=a&attendeePW=aa&checksum=check",
+ "FAILEDchecksumError"
+ "You did not pass the checksum security check")
+ response = self.client_get("/calls/bigbluebutton/join?meeting_id=%22zulip-1%22&password=%22a%22&checksum=%22check%22")
+ self.assert_json_error(response, "Error authenticating to the Big Blue Button server.")
+
+ @responses.activate
+ def test_join_bigbluebutton_redirect_server_error(self) -> None:
+ # Simulate bbb server error
+ responses.add(responses.GET,
+ "https://bbb.example.com/bigbluebutton/api/create?meetingID=zulip-1&moderatorPW=a&attendeePW=aa&checksum=check", "", status=500)
+ response = self.client_get(
+ "/calls/bigbluebutton/join?meeting_id=%22zulip-1%22&password=%22a%22&checksum=%22check%22")
+ self.assert_json_error(response, "Error connecting to the Big Blue Button server.")
+
+ @responses.activate
+ def test_join_bigbluebutton_redirect_error_by_server(self) -> None:
+ # Simulate bbb server error
+ responses.add(responses.GET,
+ "https://bbb.example.com/bigbluebutton/api/create?meetingID=zulip-1&moderatorPW=a&attendeePW=aa&checksum=check",
+ "FAILUREotherFailure")
+ response = self.client_get(
+ "/calls/bigbluebutton/join?meeting_id=%22zulip-1%22&password=%22a%22&checksum=%22check%22")
+ self.assert_json_error(response, "Big Blue Button server returned an unexpected error.")
+
+ def test_join_bigbluebutton_redirect_not_configured(self) -> None:
+ with self.settings(BIG_BLUE_BUTTON_SECRET=None,
+ BIG_BLUE_BUTTON_URL=None):
+ response = self.client_get(
+ "/calls/bigbluebutton/join?meeting_id=%22zulip-1%22&password=%22a%22&checksum=%22check%22")
+ self.assert_json_error(response, "Big Blue Button is not configured.")
diff --git a/zerver/tests/test_realm.py b/zerver/tests/test_realm.py
index ce4acf84e7..296a724403 100644
--- a/zerver/tests/test_realm.py
+++ b/zerver/tests/test_realm.py
@@ -504,7 +504,7 @@ class RealmTest(ZulipTestCase):
invite_to_stream_policy=10,
email_address_visibility=10,
message_retention_days=10,
- video_chat_provider=4,
+ video_chat_provider=10,
waiting_period_threshold=-10,
digest_weekday=10,
user_group_edit_policy=10,
@@ -539,7 +539,7 @@ class RealmTest(ZulipTestCase):
self.assertEqual(get_realm('zulip').video_chat_provider, Realm.VIDEO_CHAT_PROVIDERS['jitsi_meet']['id'])
self.login('iago')
- invalid_video_chat_provider_value = 4
+ invalid_video_chat_provider_value = 10
req = {"video_chat_provider": ujson.dumps(invalid_video_chat_provider_value)}
result = self.client_patch('/json/realm', req)
self.assert_json_error(result,
@@ -556,6 +556,12 @@ class RealmTest(ZulipTestCase):
self.assert_json_success(result)
self.assertEqual(get_realm('zulip').video_chat_provider, Realm.VIDEO_CHAT_PROVIDERS['jitsi_meet']['id'])
+ req = {"video_chat_provider": ujson.dumps(Realm.VIDEO_CHAT_PROVIDERS['big_blue_button']['id'])}
+ result = self.client_patch('/json/realm', req)
+ self.assert_json_success(result)
+ self.assertEqual(get_realm('zulip').video_chat_provider,
+ Realm.VIDEO_CHAT_PROVIDERS['big_blue_button']['id'])
+
req = {"video_chat_provider": ujson.dumps(Realm.VIDEO_CHAT_PROVIDERS['zoom']['id'])}
result = self.client_patch('/json/realm', req)
self.assert_json_success(result)
diff --git a/zerver/views/video_calls.py b/zerver/views/video_calls.py
index 6002cb86cf..3e9d4e3101 100644
--- a/zerver/views/video_calls.py
+++ b/zerver/views/video_calls.py
@@ -1,9 +1,13 @@
+import hashlib
import json
+import random
+import string
from functools import partial
from typing import Dict
-from urllib.parse import urljoin
+from urllib.parse import quote, urlencode, urljoin
import requests
+from defusedxml import ElementTree
from django.conf import settings
from django.http import HttpRequest, HttpResponse
from django.middleware import csrf
@@ -20,8 +24,9 @@ from zerver.decorator import REQ, has_request_variables, zulip_login_required
from zerver.lib.actions import do_set_zoom_token
from zerver.lib.exceptions import ErrorCode, JsonableError
from zerver.lib.pysa import mark_sanitized
-from zerver.lib.response import json_success
+from zerver.lib.response import json_error, json_success
from zerver.lib.subdomains import get_subdomain
+from zerver.lib.url_encoding import add_query_arg_to_redirect_url, add_query_to_redirect_url
from zerver.lib.validator import check_dict, check_string
from zerver.models import UserProfile, get_realm
@@ -135,7 +140,6 @@ def make_zoom_video_call(request: HttpRequest, user: UserProfile) -> HttpRespons
return json_success({"url": res.json()["join_url"]})
-
@csrf_exempt
@require_POST
@has_request_variables
@@ -155,3 +159,62 @@ def deauthorize_zoom_user(request: HttpRequest) -> HttpResponse:
auth=(settings.VIDEO_ZOOM_CLIENT_ID, settings.VIDEO_ZOOM_CLIENT_SECRET),
).raise_for_status()
return json_success()
+
+
+def get_bigbluebutton_url(request: HttpRequest, user_profile: UserProfile) -> 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 = ''.join(random.SystemRandom().choice(string.ascii_uppercase + string.digits) for _ in range(10))
+ checksum = hashlib.sha1(("create" + "meetingID=" + id + "&moderatorPW="
+ + password + "&attendeePW=" + password + "a" + settings.BIG_BLUE_BUTTON_SECRET).encode()).hexdigest()
+ url = add_query_to_redirect_url("/calls/bigbluebutton/join", urlencode({
+ "meeting_id": "\"" + id + "\"",
+ "password": "\"" + password + "\"",
+ "checksum": "\"" + checksum + "\""
+ }))
+ return json_success({"url": url})
+
+
+# We use zulip_login_required here mainly to get access to the user's
+# full name from Zulip to prepopulate the user's name in the
+# BigBlueButton meeting. Since the meeting's details are encoded in
+# the link the user is clicking, there is no validation tying this
+# meeting to the Zulip organization it was created in.
+@zulip_login_required
+@never_cache
+@has_request_variables
+def join_bigbluebutton(request: HttpRequest, meeting_id: str = REQ(validator=check_string),
+ password: str = REQ(validator=check_string),
+ checksum: str = REQ(validator=check_string)) -> HttpResponse:
+ if settings.BIG_BLUE_BUTTON_URL is None or settings.BIG_BLUE_BUTTON_SECRET is None:
+ return json_error(_("Big Blue Button is not configured."))
+ else:
+ response = requests.get(
+ add_query_to_redirect_url(settings.BIG_BLUE_BUTTON_URL + "api/create", urlencode({
+ "meetingID": meeting_id,
+ "moderatorPW": password,
+ "attendeePW": password + "a",
+ "checksum": checksum
+ })))
+ try:
+ response.raise_for_status()
+ except Exception:
+ return json_error(_("Error connecting to the Big Blue Button server."))
+
+ payload = ElementTree.fromstring(response.text)
+ if payload.find("messageKey").text == "checksumError":
+ return json_error(_("Error authenticating to the Big Blue Button server."))
+
+ if payload.find("returncode").text != "SUCCESS":
+ return json_error(_("Big Blue Button server returned an unexpected error."))
+
+ join_params = urlencode({ # type: ignore[type-var] # MyPy has an AnyStr / Union[bytes, str] mismatch here.
+ "meetingID": meeting_id,
+ "password": password,
+ "fullName": request.user.full_name
+ }, quote_via=quote)
+
+ checksum = hashlib.sha1(("join" + join_params + settings.BIG_BLUE_BUTTON_SECRET).encode()).hexdigest()
+ redirect_url_base = add_query_to_redirect_url(settings.BIG_BLUE_BUTTON_URL + "api/join", join_params)
+ return redirect(add_query_arg_to_redirect_url(redirect_url_base, "checksum=" + checksum))
diff --git a/zproject/computed_settings.py b/zproject/computed_settings.py
index d53e593706..5f7eca928d 100644
--- a/zproject/computed_settings.py
+++ b/zproject/computed_settings.py
@@ -458,6 +458,8 @@ ANDROID_GCM_API_KEY = get_secret("android_gcm_api_key")
DROPBOX_APP_KEY = get_secret("dropbox_app_key")
+BIG_BLUE_BUTTON_SECRET = get_secret('big_blue_button_secret')
+
MAILCHIMP_API_KEY = get_secret("mailchimp_api_key")
# Twitter API credentials
diff --git a/zproject/default_settings.py b/zproject/default_settings.py
index 979f3323ef..b5f956cadf 100644
--- a/zproject/default_settings.py
+++ b/zproject/default_settings.py
@@ -116,6 +116,10 @@ MAX_FILE_UPLOAD_SIZE = 25
# Jitsi Meet video call integration; set to None to disable integration.
JITSI_SERVER_URL = 'https://meet.jit.si/'
+# Allow setting BigBlueButton settings in zulip-secrets.conf in
+# development; this is useful since there are no public BigBlueButton servers.
+BIG_BLUE_BUTTON_URL = get_secret('big_blue_button_url', development_only=True)
+
# Max state storage per user
# TODO: Add this to zproject/prod_settings_template.py once stateful bots are fully functional.
USER_STATE_SIZE_LIMIT = 10000000
diff --git a/zproject/prod_settings_template.py b/zproject/prod_settings_template.py
index ec97e08cda..273b2b587f 100644
--- a/zproject/prod_settings_template.py
+++ b/zproject/prod_settings_template.py
@@ -649,3 +649,7 @@ CAMO_URI = '/external_content/'
# your own Jitsi Meet server, or if you'd like to disable the
# integration, set JITSI_SERVER_URL = None.
#JITSI_SERVER_URL = 'jitsi.example.com'
+
+# Controls the Big Blue Button video call integration. You must also
+# set big_blue_button_secret in zulip-secrets.conf.
+# BIG_BLUE_BUTTON_URL = "https://bbb.example.com/bigbluebutton/"
diff --git a/zproject/test_extra_settings.py b/zproject/test_extra_settings.py
index f100b94042..cda3b7958d 100644
--- a/zproject/test_extra_settings.py
+++ b/zproject/test_extra_settings.py
@@ -185,6 +185,9 @@ APPLE_ID_TOKEN_GENERATION_KEY = get_from_file_if_exists("zerver/tests/fixtures/a
VIDEO_ZOOM_CLIENT_ID = "client_id"
VIDEO_ZOOM_CLIENT_SECRET = "client_secret"
+BIG_BLUE_BUTTON_SECRET = "123"
+BIG_BLUE_BUTTON_URL = "https://bbb.example.com/bigbluebutton/"
+
# By default two factor authentication is disabled in tests.
# Explicitly set this to True within tests that must have this on.
TWO_FACTOR_AUTHENTICATION_ENABLED = False
diff --git a/zproject/urls.py b/zproject/urls.py
index 5c96052f58..92807dd4e4 100644
--- a/zproject/urls.py
+++ b/zproject/urls.py
@@ -405,6 +405,9 @@ v1_api_and_json_patterns = [
# Used to generate a Zoom video call URL
url(r'^calls/zoom/create$', rest_dispatch,
{'POST': 'zerver.views.video_calls.make_zoom_video_call'}),
+ # Used to generate a Big Blue Button video call URL
+ url(r'^calls/bigbluebutton/create$', rest_dispatch,
+ {'GET': 'zerver.views.video_calls.get_bigbluebutton_url'}),
# export/realm -> zerver.views.realm_export
url(r'^export/realm$', rest_dispatch,
@@ -554,6 +557,9 @@ i18n_urls = [
url(r'^calls/zoom/complete$', zerver.views.video_calls.complete_zoom_user),
url(r'^calls/zoom/deauthorize$', zerver.views.video_calls.deauthorize_zoom_user),
+ # Used to join a Big Blue Button video call
+ url(r'^calls/bigbluebutton/join$', zerver.views.video_calls.join_bigbluebutton),
+
# API and integrations documentation
url(r'^integrations/doc-html/(?P[^/]*)$',
zerver.views.documentation.integration_doc,