Repository: zulip/zulip

Issue:
Allow editing of outgoing webhook URLs
The "Edit bot" form doesn't provide a way to change the outgoing webhook URL for a bot. This means that changing a URL requires deleting a bot and re-creating. But zulip's UI doesn't allow deletion of bot... which means the only workaround is "suspending" a bot and creating a new one with a different id. That means the bot's name can't match its id, which leads to confusion (e.g. sending a message to `@xkcd` which refers to `some-other-named-bot@domain`).


![image](https://user-images.githubusercontent.com/313089/32889343-8cecbbce-ca98-11e7-84e2-19066f4a8498.png)



Discussion:
Hello @zulip/server-bots, @zulip/server-settings members, this issue was labeled with the **area: bots**, **area: settings UI** labels, so you may want to check it out!
Yeah, this is a good issue. We have #5665 for this, but it would need to be rebase and refactored a bit.

Resolving patch:
diff --git a/zerver/lib/actions.py b/zerver/lib/actions.py
--- a/zerver/lib/actions.py
+++ b/zerver/lib/actions.py
@@ -71,7 +71,8 @@
     custom_profile_fields_for_realm, get_huddle_user_ids, \
     CustomProfileFieldValue, validate_attachment_request, get_system_bot, \
     get_display_recipient_by_id, query_for_ids, get_huddle_recipient, \
-    UserGroup, UserGroupMembership, get_default_stream_groups
+    UserGroup, UserGroupMembership, get_default_stream_groups, \
+    get_service_dicts_for_bots, get_bot_services
 
 from zerver.lib.alert_words import alert_words_in_realm
 from zerver.lib.avatar import avatar_url
@@ -415,6 +416,7 @@ def stream_name(stream: Optional[Stream]) -> Optional[Text]:
                default_events_register_stream=default_events_register_stream_name,
                default_all_public_streams=user_profile.default_all_public_streams,
                avatar_url=avatar_url(user_profile),
+               services = get_service_dicts_for_bots(user_profile.id),
                )
 
     # Set the owner key only when the bot has an owner.
@@ -4380,6 +4382,24 @@ def do_update_user_group_description(user_group: UserGroup, description: Text) -
     user_group.save(update_fields=['description'])
     do_send_user_group_update_event(user_group, dict(description=description))
 
+def do_update_outgoing_webhook_service(bot_profile, service_interface, service_payload_url):
+    # type: (UserProfile, int, Text) -> None
+    # TODO: First service is chosen because currently one bot can only have one service.
+    # Update this once multiple services are supported.
+    service = get_bot_services(bot_profile.id)[0]
+    service.base_url = service_payload_url
+    service.interface = service_interface
+    service.save()
+    send_event(dict(type='realm_bot',
+                    op='update',
+                    bot=dict(email=bot_profile.email,
+                             user_id=bot_profile.id,
+                             services = [dict(base_url=service.base_url,
+                                              interface=service.interface)],
+                             ),
+                    ),
+               bot_owner_user_ids(bot_profile))
+
 def do_send_user_group_members_update_event(event_name: Text,
                                             user_group: UserGroup,
                                             user_ids: List[int]) -> None:
diff --git a/zerver/models.py b/zerver/models.py
--- a/zerver/models.py
+++ b/zerver/models.py
@@ -1460,6 +1460,7 @@ def get_owned_bot_dicts(user_profile: UserProfile,
              'default_all_public_streams': botdict['default_all_public_streams'],
              'owner': botdict['bot_owner__email'],
              'avatar_url': avatar_url_from_dict(botdict),
+             'services': get_service_dicts_for_bots(botdict['id']),
              }
             for botdict in result]
 
@@ -1909,6 +1910,14 @@ def get_realm_outgoing_webhook_services_name(realm: Realm) -> List[Any]:
 def get_bot_services(user_profile_id: str) -> List[Service]:
     return list(Service.objects.filter(user_profile__id=user_profile_id))
 
+def get_service_dicts_for_bots(user_profile_id: str) -> List[Dict[str, Any]]:
+    services = get_bot_services(user_profile_id)
+    service_dicts = [{'base_url': service.base_url,
+                      'interface': service.interface,
+                      }
+                     for service in services]
+    return service_dicts
+
 def get_service_profile(user_profile_id: str, service_name: str) -> Service:
     return Service.objects.get(user_profile__id=user_profile_id, name=service_name)
 
diff --git a/zerver/views/users.py b/zerver/views/users.py
--- a/zerver/views/users.py
+++ b/zerver/views/users.py
@@ -15,7 +15,7 @@
     do_change_is_admin, do_change_default_all_public_streams, \
     do_change_default_events_register_stream, do_change_default_sending_stream, \
     do_create_user, do_deactivate_user, do_reactivate_user, do_regenerate_api_key, \
-    check_change_full_name
+    check_change_full_name, notify_created_bot, do_update_outgoing_webhook_service
 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
@@ -156,6 +156,8 @@ def patch_bot_backend(
         request: HttpRequest, user_profile: UserProfile, email: Text,
         full_name: Optional[Text]=REQ(default=None),
         bot_owner: Optional[Text]=REQ(default=None),
+        service_payload_url: Optional[Text]=REQ(validator=check_url, default=None),
+        service_interface: Optional[int]=REQ(validator=check_int, default=1),
         default_sending_stream: Optional[Text]=REQ(default=None),
         default_events_register_stream: Optional[Text]=REQ(default=None),
         default_all_public_streams: Optional[bool]=REQ(default=None, validator=check_bool)
@@ -190,6 +192,10 @@ def patch_bot_backend(
     if default_all_public_streams is not None:
         do_change_default_all_public_streams(bot, default_all_public_streams)
 
+    if service_payload_url is not None:
+        check_valid_interface_type(service_interface)
+        do_update_outgoing_webhook_service(bot, service_interface, service_payload_url)
+
     if len(request.FILES) == 0:
         pass
     elif len(request.FILES) == 1:
@@ -203,6 +209,8 @@ def patch_bot_backend(
     json_result = dict(
         full_name=bot.full_name,
         avatar_url=avatar_url(bot),
+        service_interface = service_interface,
+        service_payload_url = service_payload_url,
         default_sending_stream=get_stream_name(bot.default_sending_stream),
         default_events_register_stream=get_stream_name(bot.default_events_register_stream),
         default_all_public_streams=bot.default_all_public_streams,
@@ -320,6 +328,8 @@ def add_bot_backend(
         for key, value in config_data.items():
             set_bot_config(bot_profile, key, value)
 
+    notify_created_bot(bot_profile)
+
     json_result = dict(
         api_key=bot_profile.api_key,
         avatar_url=avatar_url(bot_profile),


Test patch:
diff --git a/frontend_tests/node_tests/bot_data.js b/frontend_tests/node_tests/bot_data.js
--- a/frontend_tests/node_tests/bot_data.js
+++ b/frontend_tests/node_tests/bot_data.js
@@ -14,7 +14,9 @@ set_global('$', function (f) {
 set_global('document', null);
 
 var page_params = {
-    realm_bots: [{email: 'bot0@zulip.com', full_name: 'Bot 0'}],
+    realm_bots: [{email: 'bot0@zulip.com', user_id: 42, full_name: 'Bot 0'},
+                 {email: 'outgoingwebhook@zulip.com', user_id: 314, full_name: "Outgoing webhook",
+                  services: [{base_url: "http://foo.com", interface: 1}]}],
     is_admin: false,
 };
 set_global('page_params', page_params);
@@ -29,34 +31,46 @@ global.people.initialize_current_user(42);
 
 bot_data.initialize();
 // Our startup logic should have added Bot 0 from page_params.
-assert.equal(bot_data.get('bot0@zulip.com').full_name, 'Bot 0');
+assert.equal(bot_data.get(42).full_name, 'Bot 0');
+assert.equal(bot_data.get(314).full_name, 'Outgoing webhook');
 
 (function () {
     var test_bot = {
         email: 'bot1@zulip.com',
+        user_id: 43,
         avatar_url: '',
         full_name: 'Bot 1',
+        services: [{base_url: "http://bar.com", interface: 1}],
         extra: 'Not in data',
     };
 
     (function test_add() {
         bot_data.add(test_bot);
 
-        var bot = bot_data.get('bot1@zulip.com');
+        var bot = bot_data.get(43);
+        var services = bot_data.get_services(43);
         assert.equal('Bot 1', bot.full_name);
+        assert.equal('http://bar.com', services[0].base_url);
+        assert.equal(1, services[0].interface);
         assert.equal(undefined, bot.extra);
     }());
 
     (function test_update() {
         var bot;
+        var services;
 
         bot_data.add(test_bot);
 
-        bot = bot_data.get('bot1@zulip.com');
+        bot = bot_data.get(43);
         assert.equal('Bot 1', bot.full_name);
-        bot_data.update('bot1@zulip.com', {full_name: 'New Bot 1'});
-        bot = bot_data.get('bot1@zulip.com');
+        bot_data.update(43, {full_name: 'New Bot 1',
+                                           services: [{interface: 2,
+                                                       base_url: 'http://baz.com'}]});
+        bot = bot_data.get(43);
+        services = bot_data.get_services(43);
         assert.equal('New Bot 1', bot.full_name);
+        assert.equal(2, services[0].interface);
+        assert.equal('http://baz.com', services[0].base_url);
     }());
 
     (function test_remove() {
@@ -64,11 +78,11 @@ assert.equal(bot_data.get('bot0@zulip.com').full_name, 'Bot 0');
 
         bot_data.add(_.extend({}, test_bot, {is_active: true}));
 
-        bot = bot_data.get('bot1@zulip.com');
+        bot = bot_data.get(43);
         assert.equal('Bot 1', bot.full_name);
         assert(bot.is_active);
-        bot_data.deactivate('bot1@zulip.com');
-        bot = bot_data.get('bot1@zulip.com');
+        bot_data.deactivate(43);
+        bot = bot_data.get(43);
         assert.equal(bot.is_active, false);
     }());
 
@@ -77,12 +91,12 @@ assert.equal(bot_data.get('bot0@zulip.com').full_name, 'Bot 0');
 
         bot_data.add(_.extend({owner: 'owner@zulip.com'}, test_bot));
 
-        bot = bot_data.get('bot1@zulip.com');
+        bot = bot_data.get(43);
         assert(bot.can_admin);
 
         bot_data.add(_.extend({owner: 'notowner@zulip.com'}, test_bot));
 
-        bot = bot_data.get('bot1@zulip.com');
+        bot = bot_data.get(43);
         assert.equal(false, bot.can_admin);
     }());
 
@@ -92,7 +106,7 @@ assert.equal(bot_data.get('bot0@zulip.com').full_name, 'Bot 0');
 
         bot_data.add(test_bot);
 
-        bot = bot_data.get('bot1@zulip.com');
+        bot = bot_data.get(43);
         assert(bot.can_admin);
 
         page_params.is_admin = false;
@@ -101,9 +115,9 @@ assert.equal(bot_data.get('bot0@zulip.com').full_name, 'Bot 0');
     (function test_get_editable() {
         var can_admin;
 
-        bot_data.add(_.extend({}, test_bot, {owner: 'owner@zulip.com', is_active: true}));
-        bot_data.add(_.extend({}, test_bot, {email: 'bot2@zulip.com', owner: 'owner@zulip.com', is_active: true}));
-        bot_data.add(_.extend({}, test_bot, {email: 'bot3@zulip.com', owner: 'not_owner@zulip.com', is_active: true}));
+        bot_data.add(_.extend({}, test_bot, {user_id: 44, owner: 'owner@zulip.com', is_active: true}));
+        bot_data.add(_.extend({}, test_bot, {user_id: 45, email: 'bot2@zulip.com', owner: 'owner@zulip.com', is_active: true}));
+        bot_data.add(_.extend({}, test_bot, {user_id: 46, email: 'bot3@zulip.com', owner: 'not_owner@zulip.com', is_active: true}));
 
         can_admin = _.pluck(bot_data.get_editable(), 'email');
         assert.deepEqual(['bot1@zulip.com', 'bot2@zulip.com'], can_admin);
diff --git a/frontend_tests/node_tests/dispatch.js b/frontend_tests/node_tests/dispatch.js
--- a/frontend_tests/node_tests/dispatch.js
+++ b/frontend_tests/node_tests/dispatch.js
@@ -214,6 +214,7 @@ var event_fixtures = {
         op: 'remove',
         bot: {
             email: 'the-bot@example.com',
+            user_id: '42',
             full_name: 'The Bot',
         },
     },
@@ -585,8 +586,8 @@ with_overrides(function (override) {
             override('bot_data.deactivate', bot_stub.f);
             override('settings_users.update_user_data', admin_stub.f);
             dispatch(event);
-            var args = bot_stub.get_args('email');
-            assert_same(args.email, event.bot.email);
+            var args = bot_stub.get_args('user_id');
+            assert_same(args.user_id, event.bot.user_id);
 
             admin_stub.get_args('update_user_id', 'update_bot_data');
         });
@@ -600,8 +601,8 @@ with_overrides(function (override) {
 
             dispatch(event);
 
-            var args = bot_stub.get_args('email', 'bot');
-            assert_same(args.email, event.bot.email);
+            var args = bot_stub.get_args('user_id', 'bot');
+            assert_same(args.user_id, event.bot.user_id);
             assert_same(args.bot, event.bot);
 
             args = admin_stub.get_args('update_user_id', 'update_bot_data');
diff --git a/zerver/tests/test_bots.py b/zerver/tests/test_bots.py
--- a/zerver/tests/test_bots.py
+++ b/zerver/tests/test_bots.py
@@ -141,6 +141,7 @@ def test_add_bot(self) -> None:
                          default_sending_stream=None,
                          default_events_register_stream=None,
                          default_all_public_streams=False,
+                         services=[],
                          owner=self.example_email('hamlet'))
             ),
             event['event']
@@ -302,6 +303,7 @@ def test_add_bot_with_default_sending_stream_private_allowed(self) -> None:
                          default_sending_stream='Denmark',
                          default_events_register_stream=None,
                          default_all_public_streams=False,
+                         services=[],
                          owner=self.example_email('hamlet'))
             ),
             event['event']
@@ -369,6 +371,7 @@ def test_add_bot_with_default_events_register_stream_private_allowed(self) -> No
                          default_sending_stream=None,
                          default_events_register_stream='Denmark',
                          default_all_public_streams=False,
+                         services=[],
                          owner=self.example_email('hamlet'))
             ),
             event['event']
@@ -932,6 +935,47 @@ def test_patch_bogus_bot(self) -> None:
         self.assert_json_error(result, 'No such user')
         self.assert_num_bots_equal(1)
 
+    def test_patch_outgoing_webhook_bot(self) -> None:
+        self.login(self.example_email('hamlet'))
+        bot_info = {
+            'full_name': u'The Bot of Hamlet',
+            'short_name': u'hambot',
+            'bot_type': UserProfile.OUTGOING_WEBHOOK_BOT,
+            'payload_url': ujson.dumps("http://foo.bar.com"),
+            'service_interface': Service.GENERIC,
+        }
+        result = self.client_post("/json/bots", bot_info)
+        self.assert_json_success(result)
+        bot_info = {
+            'service_payload_url': ujson.dumps("http://foo.bar2.com"),
+            'service_interface': Service.SLACK,
+        }
+        result = self.client_patch("/json/bots/hambot-bot@zulip.testserver", bot_info)
+        self.assert_json_success(result)
+
+        service_interface = ujson.loads(result.content)['service_interface']
+        self.assertEqual(service_interface, Service.SLACK)
+
+        service_payload_url = ujson.loads(result.content)['service_payload_url']
+        self.assertEqual(service_payload_url, "http://foo.bar2.com")
+
+    def test_outgoing_webhook_invalid_interface(self):
+        # type: () -> None
+        self.login(self.example_email('hamlet'))
+        bot_info = {
+            'full_name': 'Outgoing Webhook test bot',
+            'short_name': 'outgoingservicebot',
+            'bot_type': UserProfile.OUTGOING_WEBHOOK_BOT,
+            'payload_url': ujson.dumps('http://127.0.0.1:5002/bots/followup'),
+            'interface_type': -1,
+        }
+        result = self.client_post("/json/bots", bot_info)
+        self.assert_json_error(result, 'Invalid interface type')
+
+        bot_info['interface_type'] = Service.GENERIC
+        result = self.client_post("/json/bots", bot_info)
+        self.assert_json_success(result)
+
     def test_create_outgoing_webhook_bot(self, **extras: Any) -> None:
         self.login(self.example_email('hamlet'))
         bot_info = {
diff --git a/zerver/tests/test_events.py b/zerver/tests/test_events.py
--- a/zerver/tests/test_events.py
+++ b/zerver/tests/test_events.py
@@ -73,6 +73,7 @@
     do_update_embedded_data,
     do_update_message,
     do_update_message_flags,
+    do_update_outgoing_webhook_service,
     do_update_pointer,
     do_update_user_presence,
     log_event,
@@ -106,10 +107,11 @@
 )
 from zerver.lib.validator import (
     check_bool, check_dict, check_dict_only, check_float, check_int, check_list, check_string,
-    equals, check_none_or, Validator
+    equals, check_none_or, Validator, check_url
 )
 
 from zerver.views.events_register import _default_all_public_streams, _default_narrow
+from zerver.views.users import add_service
 
 from zerver.tornado.event_queue import (
     allocate_client_descriptor,
@@ -1534,6 +1536,10 @@ def test_create_bot(self) -> None:
                 ('default_all_public_streams', check_bool),
                 ('avatar_url', check_string),
                 ('owner', check_string),
+                ('services', check_list(check_dict_only([  # type: ignore  # check_url doesn't completely fit the default validator spec, but is de facto working here.
+                    ('base_url', check_url),
+                    ('interface', check_int),
+                ]))),
             ])),
         ])
         action = lambda: self.create_bot('test-bot@zulip.com')
@@ -1632,6 +1638,30 @@ def test_change_bot_owner(self) -> None:
         error = change_bot_owner_checker('events[0]', events[0])
         self.assert_on_error(error)
 
+    def test_do_update_outgoing_webhook_service(self):
+        # type: () -> None
+        update_outgoing_webhook_service_checker = self.check_events_dict([
+            ('type', equals('realm_bot')),
+            ('op', equals('update')),
+            ('bot', check_dict_only([
+                ('email', check_string),
+                ('user_id', check_int),
+                ('services', check_list(check_dict_only([  # type: ignore  # check_url doesn't completely fit the default validator spec, but is de facto working here.
+                    ('base_url', check_url),
+                    ('interface', check_int),
+                ]))),
+            ])),
+        ])
+        self.user_profile = self.example_user('iago')
+        bot = do_create_user('test-bot@zulip.com', '123', get_realm('zulip'), 'Test Bot', 'test',
+                             bot_type=UserProfile.OUTGOING_WEBHOOK_BOT, bot_owner=self.user_profile)
+        add_service(user_profile=bot, name="test", base_url="http://hostname.domain1.com",
+                    interface=1, token="abced")
+        action = lambda: do_update_outgoing_webhook_service(bot, 2, 'http://hostname.domain2.com')
+        events = self.do_test(action)
+        error = update_outgoing_webhook_service_checker('events[0]', events[0])
+        self.assert_on_error(error)
+
     def test_do_deactivate_user(self) -> None:
         bot_deactivate_checker = self.check_events_dict([
             ('type', equals('realm_bot')),
@@ -1664,6 +1694,10 @@ def test_do_reactivate_user(self) -> None:
                 ('default_all_public_streams', check_bool),
                 ('avatar_url', check_string),
                 ('owner', check_none_or(check_string)),
+                ('services', check_list(check_dict_only([  # type: ignore  # check_url doesn't completely fit the default validator spec, but is de facto working here.
+                    ('base_url', check_url),
+                    ('interface', check_int),
+                ]))),
             ])),
         ])
         bot = self.create_bot('foo-bot@zulip.com')
diff --git a/zerver/tests/test_home.py b/zerver/tests/test_home.py
--- a/zerver/tests/test_home.py
+++ b/zerver/tests/test_home.py
@@ -183,7 +183,7 @@ def test_home(self) -> None:
             with patch('zerver.lib.cache.cache_set') as cache_mock:
                 result = self._get_home_page(stream='Denmark')
 
-        self.assert_length(queries, 41)
+        self.assert_length(queries, 42)
         self.assert_length(cache_mock.call_args_list, 7)
 
         html = result.content.decode('utf-8')
@@ -211,6 +211,7 @@ def test_home(self) -> None:
             'full_name',
             'is_active',
             'owner',
+            'services',
             'user_id',
         ]