diff --git a/templates/zerver/api/outgoing-webhooks.md b/templates/zerver/api/outgoing-webhooks.md index bc78301ae43ddf..f2e1a359509964 100644 --- a/templates/zerver/api/outgoing-webhooks.md +++ b/templates/zerver/api/outgoing-webhooks.md @@ -92,9 +92,37 @@ you would like to send a response message: The `content` field should contain Zulip-format Markdown. +Here's an example of the JSON your server should respond with if +you would like to add emoji reactions to the message that triggered the +outgoing webhook: +``` +{ + "reactions": [ + {"emoji_name": "wave"}, + {"emoji_name": "smile"}, + ] +} +``` + +You can also specify `emoji_code` and `reaction_type` for each emoji, +but they are not required. The API documentation for [adding an emoji +reaction](/api/add-reaction#parameters) has more details about +supported parameters. + +Here's an example of the JSON your server should respond with if +you would like to send a response message and add reactions: +``` +{ + "content": "Hey, we just received **something** from Zulip!" + "reactions": [ + {"emoji_name": "wave"} + ] +} +``` + Note that an outgoing webhook bot can use the [Zulip REST API](/api/rest) with its API key in case your bot needs to do -something else, like add an emoji reaction or upload a file. +something else, like editing a message or uploading a file. ## Slack-format webhook format diff --git a/zerver/lib/outgoing_webhook.py b/zerver/lib/outgoing_webhook.py index b571575cec0035..0b255d17cb3de4 100644 --- a/zerver/lib/outgoing_webhook.py +++ b/zerver/lib/outgoing_webhook.py @@ -11,7 +11,7 @@ from version import ZULIP_VERSION from zerver.decorator import JsonableError -from zerver.lib.actions import check_send_message +from zerver.lib.actions import check_add_reaction, check_send_message from zerver.lib.message import MessageDict from zerver.lib.outgoing_http import OutgoingSession from zerver.lib.queue import retry_event @@ -92,6 +92,12 @@ def process_success(self, response_json: Dict[str, Any]) -> Optional[Dict[str, A success_data = dict(content=content) if "widget_content" in response_json: success_data["widget_content"] = response_json["widget_content"] + if "reactions" in response_json: + success_data["reactions"] = response_json["reactions"] + return success_data + + if "reactions" in response_json: + success_data = dict(reactions=response_json["reactions"]) return success_data return None @@ -205,6 +211,39 @@ def send_response_message( ) +def add_response_reaction( + bot_id: int, message_info: Dict[str, Any], reaction_data: Dict[str, Any] +) -> None: + """ + This function is similar to send_response_reaction, but it adds reaction instead. + + bot_id is the user_id of the bot sending the response + + message_info is used to address the message to be added with the reaction and + should have at least these fields: + id - the id of the message + + reaction_data contains the information about the reaction, and can have the following fields: + emoji_name - the name of the emoji + emoji_code - optional, see check_add_reaction + reaction_type - optional, can be "unicode_emoji", "realm_emoji", or "zulip_extra_emoji" + """ + bot_user = get_user_profile_by_id(bot_id) + message_id = message_info["id"] + emoji_name = reaction_data.get("emoji_name") + + if emoji_name is not None: + check_add_reaction( + user_profile=bot_user, + message_id=message_id, + emoji_name=emoji_name, + emoji_code=reaction_data.get("emoji_code"), + reaction_type=reaction_data.get("reaction_type"), + ) + else: + raise JsonableError(_("Emoji name is missing")) + + def fail_with_message(event: Dict[str, Any], failure_message: str) -> None: bot_id = event["user_profile_id"] message_info = event["message"] @@ -300,15 +339,18 @@ def process_success_response( return content = success_data.get("content") - - if content is None or content.strip() == "": - return - - widget_content = success_data.get("widget_content") + reactions = success_data.get("reactions") bot_id = event["user_profile_id"] message_info = event["message"] - response_data = dict(content=content, widget_content=widget_content) - send_response_message(bot_id=bot_id, message_info=message_info, response_data=response_data) + + if content is not None and content.strip() != "": + widget_content = success_data.get("widget_content") + response_data = dict(content=content, widget_content=widget_content) + send_response_message(bot_id=bot_id, message_info=message_info, response_data=response_data) + + if reactions is not None and isinstance(reactions, list): + for reaction in reactions: + add_response_reaction(bot_id=bot_id, message_info=message_info, reaction_data=reaction) def do_rest_call( diff --git a/zerver/tests/test_outgoing_webhook_interfaces.py b/zerver/tests/test_outgoing_webhook_interfaces.py index e0ef231af67996..b5b822a993cf61 100644 --- a/zerver/tests/test_outgoing_webhook_interfaces.py +++ b/zerver/tests/test_outgoing_webhook_interfaces.py @@ -11,7 +11,7 @@ from zerver.lib.test_classes import ZulipTestCase from zerver.lib.timestamp import datetime_to_timestamp from zerver.lib.topic import TOPIC_NAME -from zerver.models import SLACK_INTERFACE, Message, get_realm, get_stream, get_user +from zerver.models import SLACK_INTERFACE, Message, UserProfile, get_realm, get_stream, get_user from zerver.openapi.openapi import validate_against_openapi_schema @@ -55,6 +55,38 @@ def test_process_success_response(self) -> None: response=response, ) + def test_process_success_response_with_reactions(self) -> None: + event = dict( + user_profile_id=99, + message=dict(id=103, type="private"), + ) + service_handler = self.handler + + response = mock.Mock(spec=requests.Response) + response.status_code = 200 + response.text = json.dumps(dict(content="test", reactions=[dict(emoji_name="wave")])) + + with mock.patch("zerver.lib.outgoing_webhook.send_response_message") as mock_message: + with mock.patch("zerver.lib.outgoing_webhook.add_response_reaction") as mock_reaction: + process_success_response( + event=event, + service_handler=service_handler, + response=response, + ) + self.assertTrue(mock_reaction.called) + self.assertTrue(mock_message.called) + + response.text = json.dumps(dict(reactions=[dict(emoji_code="test")])) + with mock.patch( + "zerver.lib.outgoing_webhook.get_user_profile_by_id", + return_value=mock.Mock(spec=UserProfile), + ): + with self.assertRaises(JsonableError) as m: + process_success_response( + event=event, service_handler=service_handler, response=response + ) + self.assertEqual(m.exception.msg, "Emoji name is missing") + def test_make_request(self) -> None: othello = self.example_user("othello") stream = get_stream("Denmark", othello.realm) diff --git a/zerver/tests/test_outgoing_webhook_system.py b/zerver/tests/test_outgoing_webhook_system.py index a8556215b7ce4d..546018b04e11b1 100644 --- a/zerver/tests/test_outgoing_webhook_system.py +++ b/zerver/tests/test_outgoing_webhook_system.py @@ -16,7 +16,14 @@ from zerver.lib.topic import TOPIC_NAME from zerver.lib.url_encoding import near_message_url from zerver.lib.users import add_service -from zerver.models import Recipient, Service, UserProfile, get_display_recipient, get_realm +from zerver.models import ( + Reaction, + Recipient, + Service, + UserProfile, + get_display_recipient, + get_realm, +) class ResponseMock: @@ -510,3 +517,144 @@ def test_stream_message_to_outgoing_webhook_bot(self) -> None: self.assertEqual(last_message.topic_name(), "bar") display_recipient = get_display_recipient(last_message.recipient) self.assertEqual(display_recipient, "Denmark") + + @responses.activate + def test_stream_message_to_outgoing_webhook_bot_reply_with_reactions(self) -> None: + bot_owner = self.example_user("othello") + bot = self.create_outgoing_bot(bot_owner) + + responses.add( + responses.POST, + "https://bot.example.com/", + json={ + "content": "Hidley ho, I'm a webhook responding with reactions!", + "reactions": [{"emoji_name": "wave"}], + }, + ) + + with self.assertLogs(level="INFO") as logs: + self.send_stream_message( + bot_owner, "Denmark", content=f"@**{bot.full_name}** foo", topic_name="bar" + ) + + self.assertEqual(len(responses.calls), 1) + + self.assertEqual(len(logs.output), 1) + self.assertIn(f"Outgoing webhook request from {bot.id}@zulip took ", logs.output[0]) + + # Redo the checks for stream messages to make sure that reactions actually work with messages + last_message = self.get_last_message() + self.assertEqual( + last_message.content, "Hidley ho, I'm a webhook responding with reactions!" + ) + self.assertEqual(last_message.sender_id, bot.id) + self.assertEqual(last_message.topic_name(), "bar") + display_recipient = get_display_recipient(last_message.recipient) + self.assertEqual(display_recipient, "Denmark") + + second_to_last_message = self.get_second_to_last_message() + self.assertTrue( + Reaction.objects.filter(message=second_to_last_message, emoji_name="wave").exists() + ) + + @responses.activate + def test_stream_message_to_outgoing_webhook_bot_only_add_reactions(self) -> None: + bot_owner = self.example_user("othello") + bot = self.create_outgoing_bot(bot_owner) + + responses.add( + responses.POST, + "https://bot.example.com/", + json={ + "reactions": [{"emoji_name": "wave"}], + }, + ) + + with self.assertLogs(level="INFO") as logs: + self.send_stream_message( + bot_owner, "Denmark", content=f"@**{bot.full_name}** foo", topic_name="bar" + ) + + self.assertEqual(len(responses.calls), 1) + + self.assertEqual(len(logs.output), 1) + self.assertIn(f"Outgoing webhook request from {bot.id}@zulip took ", logs.output[0]) + + last_message = self.get_last_message() + self.assertTrue(Reaction.objects.filter(message=last_message, emoji_name="wave").exists()) + + @responses.activate + def test_stream_message_to_outgoing_webhook_bot_multiple_reactions(self) -> None: + bot_owner = self.example_user("othello") + bot = self.create_outgoing_bot(bot_owner) + + responses.add( + responses.POST, + "https://bot.example.com/", + json={ + "reactions": [{"emoji_name": "wave"}, {"emoji_name": "smile"}], + }, + ) + + with self.assertLogs(level="INFO") as logs: + self.send_stream_message( + bot_owner, "Denmark", content=f"@**{bot.full_name}** foo", topic_name="bar" + ) + + self.assertEqual(len(responses.calls), 1) + + self.assertEqual(len(logs.output), 1) + self.assertIn(f"Outgoing webhook request from {bot.id}@zulip took ", logs.output[0]) + + last_message = self.get_last_message() + self.assertTrue(Reaction.objects.filter(message=last_message, emoji_name="wave").exists()) + self.assertTrue(Reaction.objects.filter(message=last_message, emoji_name="smile").exists()) + + @responses.activate + def test_pm_to_outgoing_webhook_bot_reply_with_reactions(self) -> None: + bot_owner = self.example_user("othello") + bot = self.create_outgoing_bot(bot_owner) + + responses.add( + responses.POST, + "https://bot.example.com/", + json={ + "content": "asd", + "reactions": [{"emoji_name": "wave"}], + }, + ) + + with self.assertLogs(level="INFO") as logs: + self.send_personal_message(bot_owner, bot, content="foo") + + self.assertEqual(len(responses.calls), 1) + self.assertEqual(len(logs.output), 1) + self.assertIn(f"Outgoing webhook request from {bot.id}@zulip took ", logs.output[0]) + + second_to_last_message = self.get_second_to_last_message() + self.assertTrue( + Reaction.objects.filter(message=second_to_last_message, emoji_name="wave").exists() + ) + + @responses.activate + def test_pm_to_outgoing_webhook_bot_only_add_reactions(self) -> None: + bot_owner = self.example_user("othello") + bot = self.create_outgoing_bot(bot_owner) + + responses.add( + responses.POST, + "https://bot.example.com/", + json={ + "reactions": [{"emoji_name": "wave"}], + }, + ) + + with self.assertLogs(level="INFO") as logs: + self.send_personal_message(bot_owner, bot, content="foo") + + self.assertEqual(len(responses.calls), 1) + self.assertEqual(len(logs.output), 1) + self.assertIn(f"Outgoing webhook request from {bot.id}@zulip took ", logs.output[0]) + + last_message = self.get_last_message() + self.assertTrue(Reaction.objects.filter(message=last_message, emoji_name="wave").exists())