compose: Add support for using Zoom as the video chat provider.

This adds Zoom call properties to the `Realm` model, creates endpoints
for creating calls, adds a frontend and tests.

Fixes #10979.
This commit is contained in:
Marco Burstein
2018-12-28 11:45:54 -08:00
committed by Tim Abbott
parent 1aab1594e2
commit 9ddadd39f4
18 changed files with 272 additions and 8 deletions

View File

@@ -18,6 +18,7 @@ from LDAP/active directory.
**Full feature changelog:** **Full feature changelog:**
- Added API documentation for user groups and custom emoji. - Added API documentation for user groups and custom emoji.
- Added support for using Zoom as the video chat provider.
- Added display of a user's role (administrator, guest, etc.) in - Added display of a user's role (administrator, guest, etc.) in
various relevant places. various relevant places.
- Added support for sending "topic" rather than the legacy "subject" - Added support for sending "topic" rather than the legacy "subject"

View File

@@ -1427,6 +1427,20 @@ run_test('on_events', () => {
video_link_regex = /\[Click to join video call\]\(https:\/\/hangouts.google.com\/hangouts\/\_\/zulip\/\d{15}\)/; video_link_regex = /\[Click to join video call\]\(https:\/\/hangouts.google.com\/hangouts\/\_\/zulip\/\d{15}\)/;
assert(video_link_regex.test(syntax_to_insert)); assert(video_link_regex.test(syntax_to_insert));
page_params.realm_video_chat_provider = 'Zoom';
page_params.realm_zoom_user_id = 'example@example.com';
page_params.realm_zoom_api_key = 'abc';
page_params.realm_zoom_api_secret = 'abc';
channel.get = function (options) {
assert(options.url === '/json/calls/create');
options.success({ zoom_url: 'example.zoom.com' });
};
handler(event);
video_link_regex = /\[Click to join video call\]\(example\.zoom\.com\)/;
assert(video_link_regex.test(syntax_to_insert));
page_params.jitsi_server_url = null; page_params.jitsi_server_url = null;
called = false; called = false;

View File

@@ -688,6 +688,10 @@ exports.needs_subscribe_warning = function (email) {
return true; return true;
}; };
function insert_video_call_url(url) {
var video_call_link_text = '[' + _('Click to join video call') + '](' + url + ')';
compose_ui.insert_syntax_and_focus(video_call_link_text);
}
exports.initialize = function () { exports.initialize = function () {
$('#stream_message_recipient_stream,#stream_message_recipient_topic,#private_message_recipient').on('keyup', update_fade); $('#stream_message_recipient_stream,#stream_message_recipient_topic,#private_message_recipient').on('keyup', update_fade);
@@ -940,11 +944,18 @@ exports.initialize = function () {
var video_call_id = util.random_int(100000000000000, 999999999999999); var video_call_id = util.random_int(100000000000000, 999999999999999);
if (page_params.realm_video_chat_provider === "Google Hangouts") { if (page_params.realm_video_chat_provider === "Google Hangouts") {
video_call_link = "https://hangouts.google.com/hangouts/_/" + page_params.realm_google_hangouts_domain + "/" + video_call_id; video_call_link = "https://hangouts.google.com/hangouts/_/" + page_params.realm_google_hangouts_domain + "/" + video_call_id;
insert_video_call_url(video_call_link);
} else if (page_params.realm_video_chat_provider === "Zoom") {
channel.get({
url: '/json/calls/create',
success: function (response) {
insert_video_call_url(response.zoom_url);
},
});
} else { } else {
video_call_link = page_params.jitsi_server_url + "/" + video_call_id; video_call_link = page_params.jitsi_server_url + "/" + video_call_id;
insert_video_call_url(video_call_link);
} }
var video_call_link_text = '[' + _('Click to join video call') + '](' + video_call_link + ')';
compose_ui.insert_syntax_and_focus(video_call_link_text);
}); });
$("#compose").on("click", "#markdown_preview", function (e) { $("#compose").on("click", "#markdown_preview", function (e) {

View File

@@ -46,6 +46,15 @@ var org_settings = {
google_hangouts_domain: { google_hangouts_domain: {
type: 'text', type: 'text',
}, },
zoom_user_id: {
type: 'text',
},
zoom_api_key: {
type: 'text',
},
zoom_api_secret: {
type: 'text',
},
}, },
user_defaults: { user_defaults: {
default_language: { default_language: {
@@ -243,9 +252,17 @@ function set_video_chat_provider_dropdown() {
$("#id_realm_video_chat_provider").val(chat_provider); $("#id_realm_video_chat_provider").val(chat_provider);
if (chat_provider === "Google Hangouts") { if (chat_provider === "Google Hangouts") {
$("#google_hangouts_domain").show(); $("#google_hangouts_domain").show();
$(".zoom_credentials").hide();
$("#id_realm_google_hangouts_domain").val(page_params.realm_google_hangouts_domain); $("#id_realm_google_hangouts_domain").val(page_params.realm_google_hangouts_domain);
} else if (chat_provider === "Zoom") {
$("#google_hangouts_domain").hide();
$(".zoom_credentials").show();
$("#id_realm_zoom_user_id").val(page_params.realm_zoom_user_id);
$("#id_realm_zoom_api_key").val(page_params.realm_zoom_api_key);
$("#id_realm_zoom_api_secret").val(page_params.realm_zoom_api_secret);
} else { } else {
$("#google_hangouts_domain").hide(); $("#google_hangouts_domain").hide();
$(".zoom_credentials").hide();
} }
} }
@@ -450,7 +467,8 @@ function update_dependent_subsettings(property_name) {
if (property_name === 'realm_create_stream_permission' || property_name === 'realm_waiting_period_threshold') { if (property_name === 'realm_create_stream_permission' || property_name === 'realm_waiting_period_threshold') {
set_create_stream_permission_dropdown(); set_create_stream_permission_dropdown();
} else if (property_name === 'realm_video_chat_provider' || } else if (property_name === 'realm_video_chat_provider' ||
property_name === 'realm_google_hangouts_domain') { property_name === 'realm_google_hangouts_domain' ||
property_name.startsWith('realm_zoom')) {
set_video_chat_provider_dropdown(); set_video_chat_provider_dropdown();
} else if (property_name === 'realm_msg_edit_limit_setting' || } else if (property_name === 'realm_msg_edit_limit_setting' ||
property_name === 'realm_message_content_edit_limit_minutes') { property_name === 'realm_message_content_edit_limit_minutes') {
@@ -831,11 +849,15 @@ exports.build_page = function () {
$("#id_realm_video_chat_provider").change(function (e) { $("#id_realm_video_chat_provider").change(function (e) {
var video_chat_provider = e.target.value; var video_chat_provider = e.target.value;
var node = $("#google_hangouts_domain");
if (video_chat_provider === "Google Hangouts") { if (video_chat_provider === "Google Hangouts") {
node.show(); $("#google_hangouts_domain").show();
$(".zoom_credentials").hide();
} else if (video_chat_provider === "Zoom") {
$("#google_hangouts_domain").hide();
$(".zoom_credentials").show();
} else { } else {
node.hide(); $("#google_hangouts_domain").hide();
$(".zoom_credentials").hide();
} }
}); });

View File

@@ -123,6 +123,24 @@
name="realm_google_hangouts_domain" name="realm_google_hangouts_domain"
class="admin-realm-google-hangouts-domain"/> class="admin-realm-google-hangouts-domain"/>
</div> </div>
<div id="zoom_user_id" class="zoom_credentials">
<label>{{t 'Zoom user ID (required)' }}:</label>
<input type="text" id="id_realm_zoom_user_id"
name="realm_zoom_user_id"
class="admin-realm-zoom-field"/>
</div>
<div id="zoom_api_key" class="zoom_credentials">
<label>{{t 'Zoom API key (required)' }}:</label>
<input type="text" id="id_realm_zoom_api_key"
name="realm_zoom_api_key"
class="admin-realm-zoom-field"/>
</div>
<div id="zoom_api_secret" class="zoom_credentials">
<label>{{t 'Zoom API secret (required)' }}:</label>
<input type="text" id="id_realm_zoom_api_secret"
name="realm_zoom_api_secret"
class="admin-realm-zoom-field"/>
</div>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -238,7 +238,7 @@
<h3>VIDEO CALLS</h3> <h3>VIDEO CALLS</h3>
<p> <p>
Create and join video calls with a single click. Powered Create and join video calls with a single click. Powered
by your choice of Jitsi or Google Hangouts. by your choice of Zoom, Jitsi, or Google Hangouts.
</p> </p>
</div> </div>
<div class="feature-block"> <div class="feature-block">

View File

@@ -52,6 +52,7 @@ IGNORED_PHRASES = [
r"WordPress", r"WordPress",
r"XML", r"XML",
r"Zephyr", r"Zephyr",
r"Zoom",
r"Zulip", r"Zulip",
r"Zulip Account Security", r"Zulip Account Security",
r"Zulip Security", r"Zulip Security",

View File

@@ -144,6 +144,7 @@ from zerver.lib.exceptions import JsonableError, ErrorCode, BugdownRenderingExce
from zerver.lib.sessions import delete_user_sessions from zerver.lib.sessions import delete_user_sessions
from zerver.lib.upload import attachment_url_re, attachment_url_to_path_id, \ from zerver.lib.upload import attachment_url_re, attachment_url_to_path_id, \
claim_attachment, delete_message_image, upload_emoji_image, delete_avatar_image claim_attachment, delete_message_image, upload_emoji_image, delete_avatar_image
from zerver.lib.video_calls import request_zoom_video_call_url
from zerver.tornado.event_queue import request_event_queue, send_event from zerver.tornado.event_queue import request_event_queue, send_event
from zerver.lib.types import ProfileFieldData from zerver.lib.types import ProfileFieldData
@@ -5287,3 +5288,15 @@ def do_send_realm_reactivation_email(realm: Realm) -> None:
'zerver/emails/realm_reactivation', realm, 'zerver/emails/realm_reactivation', realm,
from_address=FromAddress.tokenized_no_reply_address(), from_address=FromAddress.tokenized_no_reply_address(),
from_name="Zulip Account Security", context=context) from_name="Zulip Account Security", context=context)
def get_zoom_video_call_url(realm: Realm) -> str:
response = request_zoom_video_call_url(
realm.zoom_user_id,
realm.zoom_api_key,
realm.zoom_api_secret
)
if response is None:
return ''
return response['join_url']

24
zerver/lib/video_calls.py Normal file
View File

@@ -0,0 +1,24 @@
import requests
import jwt
from typing import Any, Dict, Optional
import time
def request_zoom_video_call_url(user_id: str, api_key: str, api_secret: str) -> Optional[Dict[str, Any]]:
encodedToken = jwt.encode({
'iss': api_key,
'exp': int(round(time.time() * 1000)) + 5000
}, api_secret, algorithm='HS256').decode('utf-8')
response = requests.post(
'https://api.zoom.us/v2/users/' + user_id + '/meetings',
headers = {
'Authorization': 'Bearer ' + encodedToken,
'content-type': 'application/json'
},
json = {}
)
if response.status_code != 200:
return None
return response.json()

View File

@@ -0,0 +1,30 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.11.16 on 2018-12-28 18:00
from __future__ import unicode_literals
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('zerver', '0200_remove_preregistrationuser_invited_as_admin'),
]
operations = [
migrations.AddField(
model_name='realm',
name='zoom_api_key',
field=models.TextField(default=''),
),
migrations.AddField(
model_name='realm',
name='zoom_api_secret',
field=models.TextField(default=''),
),
migrations.AddField(
model_name='realm',
name='zoom_user_id',
field=models.TextField(default=''),
),
]

View File

@@ -144,7 +144,7 @@ class Realm(models.Model):
MAX_GOOGLE_HANGOUTS_DOMAIN_LENGTH = 255 # This is just the maximum domain length by RFC MAX_GOOGLE_HANGOUTS_DOMAIN_LENGTH = 255 # This is just the maximum domain length by RFC
INVITES_STANDARD_REALM_DAILY_MAX = 3000 INVITES_STANDARD_REALM_DAILY_MAX = 3000
MESSAGE_VISIBILITY_LIMITED = 10000 MESSAGE_VISIBILITY_LIMITED = 10000
VIDEO_CHAT_PROVIDERS = [u"Jitsi", u"Google Hangouts"] VIDEO_CHAT_PROVIDERS = [u"Jitsi", u"Google Hangouts", u"Zoom"]
AUTHENTICATION_FLAGS = [u'Google', u'Email', u'GitHub', u'LDAP', u'Dev', u'RemoteUser', u'AzureAD'] AUTHENTICATION_FLAGS = [u'Google', u'Email', u'GitHub', u'LDAP', u'Dev', u'RemoteUser', u'AzureAD']
SUBDOMAIN_FOR_ROOT_DOMAIN = '' SUBDOMAIN_FOR_ROOT_DOMAIN = ''
@@ -262,6 +262,9 @@ class Realm(models.Model):
video_chat_provider = models.CharField(default=u"Jitsi", max_length=MAX_VIDEO_CHAT_PROVIDER_LENGTH) video_chat_provider = models.CharField(default=u"Jitsi", max_length=MAX_VIDEO_CHAT_PROVIDER_LENGTH)
google_hangouts_domain = models.TextField(default="") google_hangouts_domain = models.TextField(default="")
zoom_user_id = models.TextField(default="")
zoom_api_key = models.TextField(default="")
zoom_api_secret = models.TextField(default="")
# Define the types of the various automatically managed properties # Define the types of the various automatically managed properties
property_types = dict( property_types = dict(
@@ -277,6 +280,9 @@ class Realm(models.Model):
email_address_visibility=int, email_address_visibility=int,
email_changes_disabled=bool, email_changes_disabled=bool,
google_hangouts_domain=str, google_hangouts_domain=str,
zoom_user_id=str,
zoom_api_key=str,
zoom_api_secret=str,
invite_required=bool, invite_required=bool,
invite_by_admins_only=bool, invite_by_admins_only=bool,
inline_image_preview=bool, inline_image_preview=bool,

View File

@@ -0,0 +1,41 @@
import mock
from zerver.lib.test_classes import ZulipTestCase
from typing import Dict
class TestFeedbackBot(ZulipTestCase):
def setUp(self) -> None:
user_profile = self.example_user('hamlet')
self.login(user_profile.email, realm=user_profile.realm)
def test_create_video_call_success(self) -> None:
with mock.patch('zerver.lib.actions.request_zoom_video_call_url', return_value={'join_url': 'example.com'}):
result = self.client_get("/json/calls/create")
self.assert_json_success(result)
self.assertEqual(200, result.status_code)
content = result.json()
self.assertEqual(content['zoom_url'], 'example.com')
def test_create_video_call_failure(self) -> None:
with mock.patch('zerver.lib.actions.request_zoom_video_call_url', return_value=None):
result = self.client_get("/json/calls/create")
self.assert_json_success(result)
self.assertEqual(200, result.status_code)
content = result.json()
self.assertEqual(content['zoom_url'], '')
def test_create_video_request_success(self) -> None:
class MockResponse:
def __init__(self) -> None:
self.status_code = 200
def json(self) -> Dict[str, str]:
return {"join_url": "example.com"}
with mock.patch('requests.post', return_value=MockResponse()):
result = self.client_get("/json/calls/create")
self.assert_json_success(result)
def test_create_video_request(self) -> None:
with mock.patch('requests.post'):
result = self.client_get("/json/calls/create")
self.assert_json_success(result)

View File

@@ -1463,6 +1463,9 @@ class EventsRegisterTest(ZulipTestCase):
bot_creation_policy=[Realm.BOT_CREATION_EVERYONE], bot_creation_policy=[Realm.BOT_CREATION_EVERYONE],
video_chat_provider=[u'Google Hangouts', u'Jitsi'], video_chat_provider=[u'Google Hangouts', u'Jitsi'],
google_hangouts_domain=[u"zulip.com", u"zulip.org"], google_hangouts_domain=[u"zulip.com", u"zulip.org"],
zoom_api_secret=[u"abc", u"xyz"],
zoom_api_key=[u"abc", u"xyz"],
zoom_user_id=[u"example@example.com", u"example@example.org"]
) # type: Dict[str, Any] ) # type: Dict[str, Any]
vals = test_values.get(name) vals = test_values.get(name)

View File

@@ -171,6 +171,9 @@ class HomeTest(ZulipTestCase):
"realm_users", "realm_users",
"realm_video_chat_provider", "realm_video_chat_provider",
"realm_waiting_period_threshold", "realm_waiting_period_threshold",
"realm_zoom_api_key",
"realm_zoom_api_secret",
"realm_zoom_user_id",
"root_domain_uri", "root_domain_uri",
"save_stacktraces", "save_stacktraces",
"search_pills_enabled", "search_pills_enabled",

View File

@@ -411,6 +411,45 @@ class RealmTest(ZulipTestCase):
self.assert_json_success(result) self.assert_json_success(result)
self.assertEqual(get_realm('zulip').video_chat_provider, "Jitsi") self.assertEqual(get_realm('zulip').video_chat_provider, "Jitsi")
req = {"video_chat_provider": ujson.dumps("Zoom")}
result = self.client_patch('/json/realm', req)
self.assert_json_error(result, "Invalid user ID: user ID cannot be empty")
req = {
"video_chat_provider": ujson.dumps("Zoom"),
"zoom_user_id": ujson.dumps("example@example.com")
}
result = self.client_patch('/json/realm', req)
self.assert_json_error(result, "Invalid API key: API key cannot be empty")
req = {
"video_chat_provider": ujson.dumps("Zoom"),
"zoom_user_id": ujson.dumps("example@example.com"),
"zoom_api_key": ujson.dumps("abc")
}
result = self.client_patch('/json/realm', req)
self.assert_json_error(result, "Invalid API secret: API secret cannot be empty")
with mock.patch("zerver.views.realm.request_zoom_video_call_url", return_value=None):
req = {
"video_chat_provider": ujson.dumps("Zoom"),
"zoom_user_id": ujson.dumps("example@example.com"),
"zoom_api_key": ujson.dumps("abc"),
"zoom_api_secret": ujson.dumps("abc"),
}
result = self.client_patch('/json/realm', req)
self.assert_json_error(result, "Invalid credentials for the Zoom API.")
with mock.patch("zerver.views.realm.request_zoom_video_call_url", return_value={'join_url': 'example.com'}):
req = {
"video_chat_provider": ujson.dumps("Zoom"),
"zoom_user_id": ujson.dumps("example@example.com"),
"zoom_api_key": ujson.dumps("abc"),
"zoom_api_secret": ujson.dumps("abc"),
}
result = self.client_patch('/json/realm', req)
self.assert_json_success(result)
def test_initial_plan_type(self) -> None: def test_initial_plan_type(self) -> None:
with self.settings(BILLING_ENABLED=True): with self.settings(BILLING_ENABLED=True):
self.assertEqual(do_create_realm('hosted', 'hosted').plan_type, Realm.LIMITED) self.assertEqual(do_create_realm('hosted', 'hosted').plan_type, Realm.LIMITED)
@@ -481,6 +520,9 @@ class RealmAPITest(ZulipTestCase):
Realm.EMAIL_ADDRESS_VISIBILITY_ADMINS], Realm.EMAIL_ADDRESS_VISIBILITY_ADMINS],
video_chat_provider=[u'Jitsi', u'Hangouts'], video_chat_provider=[u'Jitsi', u'Hangouts'],
google_hangouts_domain=[u'zulip.com', u'zulip.org'], google_hangouts_domain=[u'zulip.com', u'zulip.org'],
zoom_api_secret=[u"abc", u"xyz"],
zoom_api_key=[u"abc", u"xyz"],
zoom_user_id=[u"example@example.com", u"example@example.org"]
) # type: Dict[str, Any] ) # type: Dict[str, Any]
vals = test_values.get(name) vals = test_values.get(name)
if Realm.property_types[name] is bool: if Realm.property_types[name] is bool:

View File

@@ -24,6 +24,7 @@ from zerver.lib.response import json_success, json_error
from zerver.lib.validator import check_string, check_dict, check_bool, check_int from zerver.lib.validator import check_string, check_dict, check_bool, check_int
from zerver.lib.streams import access_stream_by_id from zerver.lib.streams import access_stream_by_id
from zerver.lib.domains import validate_domain from zerver.lib.domains import validate_domain
from zerver.lib.video_calls import request_zoom_video_call_url
from zerver.models import Realm, UserProfile from zerver.models import Realm, UserProfile
from zerver.forms import check_subdomain_available as check_subdomain from zerver.forms import check_subdomain_available as check_subdomain
from confirmation.models import get_object_from_key, Confirmation, ConfirmationKeyException from confirmation.models import get_object_from_key, Confirmation, ConfirmationKeyException
@@ -63,6 +64,9 @@ def update_realm(
default_twenty_four_hour_time: Optional[bool]=REQ(validator=check_bool, default=None), default_twenty_four_hour_time: Optional[bool]=REQ(validator=check_bool, default=None),
video_chat_provider: Optional[str]=REQ(validator=check_string, default=None), video_chat_provider: Optional[str]=REQ(validator=check_string, default=None),
google_hangouts_domain: Optional[str]=REQ(validator=check_string, default=None), google_hangouts_domain: Optional[str]=REQ(validator=check_string, default=None),
zoom_user_id: Optional[str]=REQ(validator=check_string, default=None),
zoom_api_key: Optional[str]=REQ(validator=check_string, default=None),
zoom_api_secret: Optional[str]=REQ(validator=check_string, default=None),
) -> HttpResponse: ) -> HttpResponse:
realm = user_profile.realm realm = user_profile.realm
@@ -81,6 +85,21 @@ def update_realm(
validate_domain(google_hangouts_domain) validate_domain(google_hangouts_domain)
except ValidationError as e: except ValidationError as e:
return json_error(_('Invalid domain: {}').format(e.messages[0])) return json_error(_('Invalid domain: {}').format(e.messages[0]))
if video_chat_provider == "Zoom":
if not zoom_user_id:
return json_error(_('Invalid user ID: user ID cannot be empty'))
if not zoom_api_key:
return json_error(_('Invalid API key: API key cannot be empty'))
if not zoom_api_secret:
return json_error(_('Invalid API secret: API secret cannot be empty'))
# Technically, we could call some other API endpoint that
# doesn't create a video call link, but this is a nicer
# end-to-end test, since it verifies that the Zoom API user's
# scopes includes the ability to create video calls, which is
# the only capabiility we use.
if not request_zoom_video_call_url(zoom_user_id, zoom_api_key, zoom_api_secret):
return json_error(_('Invalid credentials for the %(third_party_service)s API.') % dict(
third_party_service="Zoom"))
# Additional validation of enum-style values # Additional validation of enum-style values
if bot_creation_policy is not None and bot_creation_policy not in Realm.BOT_CREATION_POLICY_TYPES: if bot_creation_policy is not None and bot_creation_policy not in Realm.BOT_CREATION_POLICY_TYPES:

View File

@@ -0,0 +1,12 @@
from django.http import HttpResponse, HttpRequest
from zerver.decorator import has_request_variables
from zerver.lib.response import json_success
from zerver.lib.actions import get_zoom_video_call_url
from zerver.models import UserProfile
@has_request_variables
def get_zoom_url(request: HttpRequest, user_profile: UserProfile) -> HttpResponse:
return json_success({'zoom_url': get_zoom_video_call_url(
user_profile.realm
)})

View File

@@ -368,6 +368,10 @@ v1_api_and_json_patterns = [
{'POST': 'zerver.views.report.report_narrow_times'}), {'POST': 'zerver.views.report.report_narrow_times'}),
url(r'^report/unnarrow_times$', rest_dispatch, url(r'^report/unnarrow_times$', rest_dispatch,
{'POST': 'zerver.views.report.report_unnarrow_times'}), {'POST': 'zerver.views.report.report_unnarrow_times'}),
# Used to generate a Zoom video call URL
url(r'^calls/create$', rest_dispatch,
{'GET': 'zerver.views.video_calls.get_zoom_url'})
] ]
# These views serve pages (HTML). As such, their internationalization # These views serve pages (HTML). As such, their internationalization