Skip to content

Commit

Permalink
outgoing webooks: Support adding reactions as response.
Browse files Browse the repository at this point in the history
send_response_reaction is created for the sake of this feature, which
is implemented in a way similar to send_response_message. Since this
change, the outgoing webhooks will be able to respond to messages only
with reactions, though sending a response message at the same time is
also supported.

Fixes: zulip#12811
  • Loading branch information
PIG208 committed May 10, 2021
1 parent 2490008 commit 3b9324f
Show file tree
Hide file tree
Showing 4 changed files with 261 additions and 11 deletions.
30 changes: 29 additions & 1 deletion templates/zerver/api/outgoing-webhooks.md
Expand Up @@ -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

Expand Down
58 changes: 50 additions & 8 deletions zerver/lib/outgoing_webhook.py
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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"]
Expand Down Expand Up @@ -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(
Expand Down
34 changes: 33 additions & 1 deletion zerver/tests/test_outgoing_webhook_interfaces.py
Expand Up @@ -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


Expand Down Expand Up @@ -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)
Expand Down
150 changes: 149 additions & 1 deletion zerver/tests/test_outgoing_webhook_system.py
Expand Up @@ -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:
Expand Down Expand Up @@ -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())

0 comments on commit 3b9324f

Please sign in to comment.