Repository: zulip/zulip

Issue:
Don't send empty `team_id` in Slack-format outgoing webhooks, when `string_id` empty
Discussed here: https://chat.zulip.org/#narrow/stream/127-integrations/topic/gitlab.20slash.20commands/near/1247658

On a single-realm server slack-compatible outgoing webhook sends an empty `team_id`.

GitLab's [Slack slash commands](https://docs.gitlab.com/ee/user/project/integrations/slack_slash_commands.html), for example, relies on the `team_id` field being non-empty.


Discussion:
Hello @zulip/server-integrations members, this issue was labeled with the "area: integrations" label, so you may want to check it out!

<!-- areaLabelAddition -->


Resolving patch:
diff --git a/zerver/lib/outgoing_webhook.py b/zerver/lib/outgoing_webhook.py
--- a/zerver/lib/outgoing_webhook.py
+++ b/zerver/lib/outgoing_webhook.py
@@ -20,9 +20,9 @@
 from zerver.models import (
     GENERIC_INTERFACE,
     SLACK_INTERFACE,
+    Realm,
     Service,
     UserProfile,
-    email_to_domain,
     get_client,
     get_user_profile_by_id,
 )
@@ -40,7 +40,9 @@ def __init__(self, token: str, user_profile: UserProfile, service_name: str) ->
         )
 
     @abc.abstractmethod
-    def make_request(self, base_url: str, event: Dict[str, Any]) -> Optional[Response]:
+    def make_request(
+        self, base_url: str, event: Dict[str, Any], realm: Realm
+    ) -> Optional[Response]:
         raise NotImplementedError
 
     @abc.abstractmethod
@@ -49,7 +51,9 @@ def process_success(self, response_json: Dict[str, Any]) -> Optional[Dict[str, A
 
 
 class GenericOutgoingWebhookService(OutgoingWebhookServiceInterface):
-    def make_request(self, base_url: str, event: Dict[str, Any]) -> Optional[Response]:
+    def make_request(
+        self, base_url: str, event: Dict[str, Any], realm: Realm
+    ) -> Optional[Response]:
         """
         We send a simple version of the message to outgoing
         webhooks, since most of them really only need
@@ -98,20 +102,38 @@ def process_success(self, response_json: Dict[str, Any]) -> Optional[Dict[str, A
 
 
 class SlackOutgoingWebhookService(OutgoingWebhookServiceInterface):
-    def make_request(self, base_url: str, event: Dict[str, Any]) -> Optional[Response]:
+    def make_request(
+        self, base_url: str, event: Dict[str, Any], realm: Realm
+    ) -> Optional[Response]:
         if event["message"]["type"] == "private":
             failure_message = "Slack outgoing webhooks don't support private messages."
             fail_with_message(event, failure_message)
             return None
 
+        # https://api.slack.com/legacy/custom-integrations/outgoing-webhooks#legacy-info__post-data
+        # documents the Slack outgoing webhook format:
+        #
+        # token=XXXXXXXXXXXXXXXXXX
+        # team_id=T0001
+        # team_domain=example
+        # channel_id=C2147483705
+        # channel_name=test
+        # thread_ts=1504640714.003543
+        # timestamp=1504640775.000005
+        # user_id=U2147483697
+        # user_name=Steve
+        # text=googlebot: What is the air-speed velocity of an unladen swallow?
+        # trigger_word=googlebot:
+
         request_data = [
             ("token", self.token),
-            ("team_id", event["message"]["sender_realm_str"]),
-            ("team_domain", email_to_domain(event["message"]["sender_email"])),
-            ("channel_id", event["message"]["stream_id"]),
+            ("team_id", f"T{realm.id}"),
+            ("team_domain", realm.host),
+            ("channel_id", f"C{event['message']['stream_id']}"),
             ("channel_name", event["message"]["display_recipient"]),
+            ("thread_ts", event["message"]["timestamp"]),
             ("timestamp", event["message"]["timestamp"]),
-            ("user_id", event["message"]["sender_id"]),
+            ("user_id", f"U{event['message']['sender_id']}"),
             ("user_name", event["message"]["sender_full_name"]),
             ("text", event["command"]),
             ("trigger_word", event["trigger"]),
@@ -326,11 +348,12 @@ def do_rest_call(
     """Returns response of call if no exception occurs."""
     try:
         start_time = perf_counter()
+        bot_profile = service_handler.user_profile
         response = service_handler.make_request(
             base_url,
             event,
+            bot_profile.realm,
         )
-        bot_profile = service_handler.user_profile
         logging.info(
             "Outgoing webhook request from %s@%s took %f seconds",
             bot_profile.id,


Test patch:
diff --git a/zerver/tests/test_outgoing_webhook_interfaces.py b/zerver/tests/test_outgoing_webhook_interfaces.py
--- a/zerver/tests/test_outgoing_webhook_interfaces.py
+++ b/zerver/tests/test_outgoing_webhook_interfaces.py
@@ -107,6 +107,7 @@ def test_make_request(self) -> None:
             self.handler.make_request(
                 test_url,
                 event,
+                othello.realm,
             )
             session.post.assert_called_once()
             self.assertEqual(session.post.call_args[0], (test_url,))
@@ -150,6 +151,7 @@ def test_process_success(self) -> None:
 class TestSlackOutgoingWebhookService(ZulipTestCase):
     def setUp(self) -> None:
         super().setUp()
+        self.bot_user = get_user("outgoing-webhook@zulip.com", get_realm("zulip"))
         self.stream_message_event = {
             "command": "@**test**",
             "user_profile_id": 12,
@@ -187,7 +189,9 @@ def setUp(self) -> None:
         }
 
         service_class = get_service_interface_class(SLACK_INTERFACE)
-        self.handler = service_class(token="abcdef", user_profile=None, service_name="test-service")
+        self.handler = service_class(
+            token="abcdef", user_profile=self.bot_user, service_name="test-service"
+        )
 
     def test_make_request_stream_message(self) -> None:
         test_url = "https://example.com/example"
@@ -195,22 +199,24 @@ def test_make_request_stream_message(self) -> None:
             self.handler.make_request(
                 test_url,
                 self.stream_message_event,
+                self.bot_user.realm,
             )
             session.post.assert_called_once()
             self.assertEqual(session.post.call_args[0], (test_url,))
             request_data = session.post.call_args[1]["data"]
 
         self.assertEqual(request_data[0][1], "abcdef")  # token
-        self.assertEqual(request_data[1][1], "zulip")  # team_id
-        self.assertEqual(request_data[2][1], "zulip.com")  # team_domain
-        self.assertEqual(request_data[3][1], "123")  # channel_id
+        self.assertEqual(request_data[1][1], "T2")  # team_id
+        self.assertEqual(request_data[2][1], "zulip.testserver")  # team_domain
+        self.assertEqual(request_data[3][1], "C123")  # channel_id
         self.assertEqual(request_data[4][1], "integrations")  # channel_name
-        self.assertEqual(request_data[5][1], 123456)  # timestamp
-        self.assertEqual(request_data[6][1], 21)  # user_id
-        self.assertEqual(request_data[7][1], "Sample User")  # user_name
-        self.assertEqual(request_data[8][1], "@**test**")  # text
-        self.assertEqual(request_data[9][1], "mention")  # trigger_word
-        self.assertEqual(request_data[10][1], 12)  # user_profile_id
+        self.assertEqual(request_data[5][1], 123456)  # thread_id
+        self.assertEqual(request_data[6][1], 123456)  # timestamp
+        self.assertEqual(request_data[7][1], "U21")  # user_id
+        self.assertEqual(request_data[8][1], "Sample User")  # user_name
+        self.assertEqual(request_data[9][1], "@**test**")  # text
+        self.assertEqual(request_data[10][1], "mention")  # trigger_word
+        self.assertEqual(request_data[11][1], 12)  # user_profile_id
 
     @mock.patch("zerver.lib.outgoing_webhook.fail_with_message")
     def test_make_request_private_message(self, mock_fail_with_message: mock.Mock) -> None:
@@ -219,6 +225,7 @@ def test_make_request_private_message(self, mock_fail_with_message: mock.Mock) -
             response = self.handler.make_request(
                 test_url,
                 self.private_message_event,
+                self.bot_user.realm,
             )
             session.post.assert_not_called()
         self.assertIsNone(response)