embedded bots: Add config data UI.

This adds UI fields in the bot settings for specifying
configuration values like API keys for a bot. The names
and placeholder values for each bot's config fields are
fetched from the bot's <bot>.conf template file in the
zulip_bots package. This also adds giphy and followup
as embedded bots.
This commit is contained in:
Robert Hönig
2018-01-07 19:24:14 +01:00
committed by showell
parent ffa7637215
commit d1d8365a6b
9 changed files with 111 additions and 11 deletions

View File

@@ -1,6 +1,9 @@
set_global("page_params", { set_global("page_params", {
realm_uri: "https://chat.example.com", realm_uri: "https://chat.example.com",
realm_embedded_bots: ["converter", "xkcd"], realm_embedded_bots: [{name: "converter", config: {}},
{name:"giphy", config: {key: "12345678"}},
{name:"foobot", config: {bar: "baz", qux: "quux"}},
],
}); });
set_global("avatar", {}); set_global("avatar", {});
@@ -10,6 +13,8 @@ set_global('document', 'document-stub');
zrequire('bot_data'); zrequire('bot_data');
zrequire('settings_bots'); zrequire('settings_bots');
zrequire('Handlebars', 'handlebars');
zrequire('templates');
(function test_generate_zuliprc_uri() { (function test_generate_zuliprc_uri() {
var bot = { var bot = {
@@ -55,6 +60,7 @@ zrequire('settings_bots');
function test_create_bot_type_input_box_toggle(f) { function test_create_bot_type_input_box_toggle(f) {
var create_payload_url = $('#create_payload_url'); var create_payload_url = $('#create_payload_url');
var payload_url_inputbox = $('#payload_url_inputbox'); var payload_url_inputbox = $('#payload_url_inputbox');
var config_inputbox = $('#config_inputbox');
var EMBEDDED_BOT_TYPE = '4'; var EMBEDDED_BOT_TYPE = '4';
var OUTGOING_WEBHOOK_BOT_TYPE = '3'; var OUTGOING_WEBHOOK_BOT_TYPE = '3';
var GENERIC_BOT_TYPE = '1'; var GENERIC_BOT_TYPE = '1';
@@ -65,16 +71,19 @@ function test_create_bot_type_input_box_toggle(f) {
assert(!payload_url_inputbox.visible()); assert(!payload_url_inputbox.visible());
assert($('#select_service_name').hasClass('required')); assert($('#select_service_name').hasClass('required'));
assert($('#service_name_list').visible()); assert($('#service_name_list').visible());
assert(config_inputbox.visible());
$('#create_bot_type :selected').val(OUTGOING_WEBHOOK_BOT_TYPE); $('#create_bot_type :selected').val(OUTGOING_WEBHOOK_BOT_TYPE);
f.apply(); f.apply();
assert(create_payload_url.hasClass('required')); assert(create_payload_url.hasClass('required'));
assert(payload_url_inputbox.visible()); assert(payload_url_inputbox.visible());
assert(!config_inputbox.visible());
$('#create_bot_type :selected').val(GENERIC_BOT_TYPE); $('#create_bot_type :selected').val(GENERIC_BOT_TYPE);
f.apply(); f.apply();
assert(!(create_payload_url.hasClass('required'))); assert(!(create_payload_url.hasClass('required')));
assert(!payload_url_inputbox.visible()); assert(!payload_url_inputbox.visible());
assert(!config_inputbox.visible());
} }
(function test_set_up() { (function test_set_up() {
@@ -93,14 +102,27 @@ function test_create_bot_type_input_box_toggle(f) {
}; };
var embedded_bots_added = 0; var embedded_bots_added = 0;
var config_fields_added = 0;
$('#select_service_name').append = function () { $('#select_service_name').append = function () {
embedded_bots_added += 1; embedded_bots_added += 1;
}; };
$('#config_inputbox').append = function () {
config_fields_added += 1;
};
$('#config_inputbox').children = function () {
var mock_children = {
hide: function () {
return;
},
};
return mock_children;
};
global.compile_template('embedded_bot_config_item');
avatar.build_bot_create_widget = function () {}; avatar.build_bot_create_widget = function () {};
avatar.build_bot_edit_widget = function () {}; avatar.build_bot_edit_widget = function () {};
settings_bots.set_up(); settings_bots.set_up();
assert(embedded_bots_added === page_params.realm_embedded_bots.length); assert(embedded_bots_added === page_params.realm_embedded_bots.length);
assert(config_fields_added === 3);
}()); }());

View File

@@ -1427,6 +1427,19 @@ function render(template_name, args) {
assert.equal($(html).find("tr").data("topic"), "Verona2"); assert.equal($(html).find("tr").data("topic"), "Verona2");
}()); }());
(function embedded_bot_config_item() {
var args = {
botname: 'giphy',
key: 'api_key',
value: '12345678',
};
var html = render('embedded_bot_config_item', args);
assert.equal($(html).attr('name'), args.botname);
assert.equal($(html).attr('id'), args.botname+'_'+args.key);
assert.equal($(html).find('label').text(), args.key);
assert.equal($(html).find('input').attr('placeholder'), args.value);
}());
// By the end of this test, we should have compiled all our templates. Ideally, // By the end of this test, we should have compiled all our templates. Ideally,
// we will also have exercised them to some degree, but that's a little trickier // we will also have exercised them to some degree, but that's a little trickier
// to enforce. // to enforce.

View File

@@ -86,13 +86,22 @@ exports.set_up = function () {
$('#payload_url_inputbox').hide(); $('#payload_url_inputbox').hide();
$('#create_payload_url').val(''); $('#create_payload_url').val('');
$('#service_name_list').hide(); $('#service_name_list').hide();
page_params.realm_embedded_bots.forEach(function (bot_name) { $('#config_inputbox').hide();
page_params.realm_embedded_bots.forEach(function (bot) {
$('#select_service_name').append($('<option>', { $('#select_service_name').append($('<option>', {
value: bot_name, value: bot.name,
text: bot_name, text: bot.name,
})); }));
_.each(bot.config, function (key) {
var rendered_config_item = templates.render('embedded_bot_config_item',
{botname: bot.name, key: key, value: bot.config[key]});
$('#config_inputbox').append(rendered_config_item);
});
}); });
$('#select_service_name').val('converter'); // TODO: Use 'select a bot'. var selected_embedded_bot = 'converter';
$('#select_service_name').val(selected_embedded_bot); // TODO: Use 'select a bot'.
$('#config_inputbox').children().hide();
$("[name*='"+selected_embedded_bot+"']").show();
$('#download_flaskbotrc').click(function () { $('#download_flaskbotrc').click(function () {
var OUTGOING_WEBHOOK_BOT_TYPE_INT = 3; var OUTGOING_WEBHOOK_BOT_TYPE_INT = 3;
@@ -153,6 +162,11 @@ exports.set_up = function () {
formData.append('interface_type', interface_type); formData.append('interface_type', interface_type);
} else if (bot_type === EMBEDDED_BOT_TYPE) { } else if (bot_type === EMBEDDED_BOT_TYPE) {
formData.append('service_name', service_name); formData.append('service_name', service_name);
var config_data = {};
$("[name*='"+service_name+"'] input").each(function () {
config_data[$(this).attr('name')] = $(this).val();
});
formData.append('config_data', JSON.stringify(config_data));
} }
jQuery.each($('#bot_avatar_file_input')[0].files, function (i, file) { jQuery.each($('#bot_avatar_file_input')[0].files, function (i, file) {
formData.append('file-'+i, file); formData.append('file-'+i, file);
@@ -170,6 +184,10 @@ exports.set_up = function () {
$('#create_bot_short_name').val(''); $('#create_bot_short_name').val('');
$('#create_payload_url').val(''); $('#create_payload_url').val('');
$('#payload_url_inputbox').hide(); $('#payload_url_inputbox').hide();
$('#config_inputbox').hide();
$("[name*='"+service_name+"'] input").each(function () {
$(this).val('');
});
$('#create_bot_type').val(GENERIC_BOT_TYPE); $('#create_bot_type').val(GENERIC_BOT_TYPE);
$('#select_service_name').val('converter'); // TODO: Later we can change this to hello bot or similar $('#select_service_name').val('converter'); // TODO: Later we can change this to hello bot or similar
$('#service_name_list').hide(); $('#service_name_list').hide();
@@ -194,6 +212,7 @@ exports.set_up = function () {
// For "generic bot" or "incoming webhook" both these fields need not be displayed. // For "generic bot" or "incoming webhook" both these fields need not be displayed.
$('#service_name_list').hide(); $('#service_name_list').hide();
$('#select_service_name').removeClass('required'); $('#select_service_name').removeClass('required');
$('#config_inputbox').hide();
$('#payload_url_inputbox').hide(); $('#payload_url_inputbox').hide();
$('#create_payload_url').removeClass('required'); $('#create_payload_url').removeClass('required');
@@ -204,9 +223,17 @@ exports.set_up = function () {
} else if (bot_type === EMBEDDED_BOT_TYPE) { } else if (bot_type === EMBEDDED_BOT_TYPE) {
$('#service_name_list').show(); $('#service_name_list').show();
$('#select_service_name').addClass('required'); $('#select_service_name').addClass('required');
$("#select_service_name").trigger('change');
$('#config_inputbox').show();
} }
}); });
$("#select_service_name").on("change", function () {
$('#config_inputbox').children().hide();
var selected_bot = $('#select_service_name :selected').val();
$("[name*='"+selected_bot+"']").show();
});
$("#active_bots_list").on("click", "button.delete_bot", function (e) { $("#active_bots_list").on("click", "button.delete_bot", function (e) {
var email = $(e.currentTarget).data('email'); var email = $(e.currentTarget).data('email');
channel.del({ channel.del({

View File

@@ -0,0 +1,5 @@
<div class="input-group" name="{{botname}}" id="{{botname}}_{{key}}">
<label for="{{botname}}_{{key}}_input">{{key}}</label>
<input type="text" name="{{key}}" id="{{botname}}_{{key}}_input"
maxlength=1000 placeholder="{{value}}" value="" />
</div>

View File

@@ -78,6 +78,8 @@
<select name="service_name" id="select_service_name"> <select name="service_name" id="select_service_name">
</select> </select>
</div> </div>
<div id="config_inputbox">
</div>
<div class="input-group"> <div class="input-group">
<div id="bot_avatar_file"></div> <div id="bot_avatar_file"></div>
<input type="file" name="bot_avatar_file_input" class="notvisible" id="bot_avatar_file_input" value="{{t 'Upload avatar' }}" /> <input type="file" name="bot_avatar_file_input" class="notvisible" id="bot_avatar_file_input" value="{{t 'Upload avatar' }}" />

View File

@@ -16,6 +16,7 @@ session_engine = import_module(settings.SESSION_ENGINE)
from zerver.lib.alert_words import user_alert_words from zerver.lib.alert_words import user_alert_words
from zerver.lib.attachments import user_attachments from zerver.lib.attachments import user_attachments
from zerver.lib.avatar import avatar_url, get_avatar_field from zerver.lib.avatar import avatar_url, get_avatar_field
from zerver.lib.bot_config import load_bot_config_template
from zerver.lib.hotspots import get_next_hotspots from zerver.lib.hotspots import get_next_hotspots
from zerver.lib.integrations import EMBEDDED_BOTS from zerver.lib.integrations import EMBEDDED_BOTS
from zerver.lib.message import ( from zerver.lib.message import (
@@ -218,7 +219,11 @@ def fetch_initial_state_data(user_profile: UserProfile,
# This does not yet have an apply_event counterpart, since currently, # This does not yet have an apply_event counterpart, since currently,
# new entries for EMBEDDED_BOTS can only be added directly in the codebase. # new entries for EMBEDDED_BOTS can only be added directly in the codebase.
if want('realm_embedded_bots'): if want('realm_embedded_bots'):
state['realm_embedded_bots'] = list(bot.name for bot in EMBEDDED_BOTS) realm_embedded_bots = []
for bot in EMBEDDED_BOTS:
realm_embedded_bots.append({'name': bot.name,
'config': load_bot_config_template(bot.name)})
state['realm_embedded_bots'] = realm_embedded_bots
if want('subscription'): if want('subscription'):
subscriptions, unsubscribed, never_subscribed = gather_subscriptions_helper( subscriptions, unsubscribed, never_subscribed = gather_subscriptions_helper(

View File

@@ -256,6 +256,8 @@ EMBEDDED_BOTS = [
EmbeddedBotIntegration('encrypt', []), EmbeddedBotIntegration('encrypt', []),
EmbeddedBotIntegration('helloworld', []), EmbeddedBotIntegration('helloworld', []),
EmbeddedBotIntegration('virtual_fs', []), EmbeddedBotIntegration('virtual_fs', []),
EmbeddedBotIntegration('giphy', []),
EmbeddedBotIntegration('followup', []),
] # type: List[EmbeddedBotIntegration] ] # type: List[EmbeddedBotIntegration]
WEBHOOK_INTEGRATIONS = [ WEBHOOK_INTEGRATIONS = [

View File

@@ -10,6 +10,7 @@ from mock import patch
from typing import Any, Dict, List, Mapping from typing import Any, Dict, List, Mapping
from zerver.lib.actions import do_change_stream_invite_only from zerver.lib.actions import do_change_stream_invite_only
from zerver.lib.bot_config import get_bot_config
from zerver.models import get_realm, get_stream, \ from zerver.models import get_realm, get_stream, \
Realm, Stream, UserProfile, get_user, get_bot_services, Service, \ Realm, Stream, UserProfile, get_user, get_bot_services, Service, \
is_cross_realm_bot_email is_cross_realm_bot_email
@@ -994,11 +995,13 @@ class BotTest(ZulipTestCase, UploadSerializeMixin):
self.login(self.example_email('hamlet')) self.login(self.example_email('hamlet'))
# Test to create embedded bot with correct service_name # Test to create embedded bot with correct service_name
bot_config_info = {'key': 'value'}
bot_info = { bot_info = {
'full_name': 'Embedded test bot', 'full_name': 'Embedded test bot',
'short_name': 'embeddedservicebot', 'short_name': 'embeddedservicebot',
'bot_type': UserProfile.EMBEDDED_BOT, 'bot_type': UserProfile.EMBEDDED_BOT,
'service_name': 'converter', 'service_name': 'followup',
'config_data': ujson.dumps(bot_config_info),
} }
bot_info.update(extras) bot_info.update(extras)
with self.settings(EMBEDDED_BOTS_ENABLED=False): with self.settings(EMBEDDED_BOTS_ENABLED=False):
@@ -1013,9 +1016,10 @@ class BotTest(ZulipTestCase, UploadSerializeMixin):
bot = get_user(bot_email, bot_realm) bot = get_user(bot_email, bot_realm)
services = get_bot_services(bot.id) services = get_bot_services(bot.id)
service = services[0] service = services[0]
bot_config = get_bot_config(bot)
self.assertEqual(bot_config, bot_config_info)
self.assertEqual(len(services), 1) self.assertEqual(len(services), 1)
self.assertEqual(service.name, "converter") self.assertEqual(service.name, "followup")
self.assertEqual(service.user_profile, bot) self.assertEqual(service.user_profile, bot)
# Test to create embedded bot with incorrect service_name # Test to create embedded bot with incorrect service_name
@@ -1029,6 +1033,19 @@ class BotTest(ZulipTestCase, UploadSerializeMixin):
result = self.client_post("/json/bots", bot_info) result = self.client_post("/json/bots", bot_info)
self.assert_json_error(result, 'Invalid embedded bot name.') self.assert_json_error(result, 'Invalid embedded bot name.')
# Test to create embedded bot with an invalid config value
malformatted_bot_config_info = {'foo': ['bar', 'baz']}
bot_info = {
'full_name': 'Embedded test bot',
'short_name': 'embeddedservicebot2',
'bot_type': UserProfile.EMBEDDED_BOT,
'service_name': 'followup',
'config_data': ujson.dumps(malformatted_bot_config_info)
}
bot_info.update(extras)
result = self.client_post("/json/bots", bot_info)
self.assert_json_error(result, 'config_data contains a value that is not a string')
def test_is_cross_realm_bot_email(self) -> None: def test_is_cross_realm_bot_email(self) -> None:
self.assertTrue(is_cross_realm_bot_email("notification-bot@zulip.com")) self.assertTrue(is_cross_realm_bot_email("notification-bot@zulip.com"))
self.assertTrue(is_cross_realm_bot_email("notification-BOT@zulip.com")) self.assertTrue(is_cross_realm_bot_email("notification-BOT@zulip.com"))

View File

@@ -17,13 +17,14 @@ from zerver.lib.actions import do_change_avatar_fields, do_change_bot_owner, \
do_create_user, do_deactivate_user, do_reactivate_user, do_regenerate_api_key, \ do_create_user, do_deactivate_user, do_reactivate_user, do_regenerate_api_key, \
check_change_full_name check_change_full_name
from zerver.lib.avatar import avatar_url, get_gravatar_url, get_avatar_field from zerver.lib.avatar import avatar_url, get_gravatar_url, get_avatar_field
from zerver.lib.bot_config import set_bot_config
from zerver.lib.exceptions import JsonableError from zerver.lib.exceptions import JsonableError
from zerver.lib.integrations import EMBEDDED_BOTS from zerver.lib.integrations import EMBEDDED_BOTS
from zerver.lib.request import has_request_variables, REQ from zerver.lib.request import has_request_variables, REQ
from zerver.lib.response import json_error, json_success from zerver.lib.response import json_error, json_success
from zerver.lib.streams import access_stream_by_name from zerver.lib.streams import access_stream_by_name
from zerver.lib.upload import upload_avatar_image from zerver.lib.upload import upload_avatar_image
from zerver.lib.validator import check_bool, check_string, check_int, check_url from zerver.lib.validator import check_bool, check_string, check_int, check_url, check_dict
from zerver.lib.users import check_valid_bot_type, \ from zerver.lib.users import check_valid_bot_type, \
check_full_name, check_short_name, check_valid_interface_type check_full_name, check_short_name, check_valid_interface_type
from zerver.lib.utils import generate_random_token from zerver.lib.utils import generate_random_token
@@ -246,6 +247,8 @@ def add_bot_backend(
bot_type: int=REQ(validator=check_int, default=UserProfile.DEFAULT_BOT), bot_type: int=REQ(validator=check_int, default=UserProfile.DEFAULT_BOT),
payload_url: Optional[Text]=REQ(validator=check_url, default=""), payload_url: Optional[Text]=REQ(validator=check_url, default=""),
service_name: Optional[Text]=REQ(default=None), service_name: Optional[Text]=REQ(default=None),
config_data: Optional[Dict[Text, Text]]=REQ(default=None,
validator=check_dict(value_validator=check_string)),
interface_type: int=REQ(validator=check_int, default=Service.GENERIC), interface_type: int=REQ(validator=check_int, default=Service.GENERIC),
default_sending_stream_name: Optional[Text]=REQ('default_sending_stream', default=None), default_sending_stream_name: Optional[Text]=REQ('default_sending_stream', default=None),
default_events_register_stream_name: Optional[Text]=REQ('default_events_register_stream', default_events_register_stream_name: Optional[Text]=REQ('default_events_register_stream',
@@ -313,6 +316,10 @@ def add_bot_backend(
interface=interface_type, interface=interface_type,
token=random_api_key()) token=random_api_key())
if bot_type == UserProfile.EMBEDDED_BOT:
for key, value in config_data.items():
set_bot_config(bot_profile, key, value)
json_result = dict( json_result = dict(
api_key=bot_profile.api_key, api_key=bot_profile.api_key,
avatar_url=avatar_url(bot_profile), avatar_url=avatar_url(bot_profile),