From 69517f5ac5528be151bd4e52e4be71ed95145153 Mon Sep 17 00:00:00 2001 From: Steve Howell Date: Mon, 21 May 2018 13:23:46 +0000 Subject: [PATCH] Support zform-based widget content in the server. API users, particularly bots, can now send a field called "widget_content" that will be turned into a submessage for the web app to look at. (Other clients can still rely on "content" to be there, although it's up to the bot author to make the experience good for those clients as well.) Right now widget_content will be a JSON string that encodes a "zform" widget with "choices." Our first example will be a trivia bot, where users will see something like this: Which fruit is orange in color? [A] orange [B] blackberry [C] strawberry The letters will be turned into buttons on the webapp and have canned replies. This commit has a few parts: - receive widget_content in the request (simply validating that it's a string) - parse the JSON in check_message and deeply validate its structure - turn it into a submessage in widget.py --- zerver/lib/actions.py | 23 ++++++++++++--- zerver/lib/validator.py | 45 +++++++++++++++++++++++++++++ zerver/lib/widget.py | 7 +++++ zerver/tests/test_widgets.py | 56 ++++++++++++++++++++++++++++++++++++ zerver/views/messages.py | 4 ++- 5 files changed, 130 insertions(+), 5 deletions(-) create mode 100644 zerver/tests/test_widgets.py diff --git a/zerver/lib/actions.py b/zerver/lib/actions.py index 78da76e09c..fc33afb00f 100644 --- a/zerver/lib/actions.py +++ b/zerver/lib/actions.py @@ -88,6 +88,7 @@ from zerver.models import Realm, RealmEmoji, Stream, UserProfile, UserActivity, from zerver.lib.alert_words import alert_words_in_realm from zerver.lib.avatar import avatar_url, avatar_url_from_dict from zerver.lib.stream_recipient import StreamRecipientMap +from zerver.lib.validator import check_widget_content from zerver.lib.widget import do_widget_post_save_actions, \ do_widget_pre_save_actions @@ -1816,7 +1817,8 @@ def check_send_message(sender: UserProfile, client: Client, message_type_name: s forged: bool=False, forged_timestamp: Optional[float]=None, forwarder_user_profile: Optional[UserProfile]=None, local_id: Optional[str]=None, - sender_queue_id: Optional[str]=None) -> int: + sender_queue_id: Optional[str]=None, + widget_content: Optional[str]=None) -> int: addressee = Addressee.legacy_build( sender, @@ -1826,7 +1828,8 @@ def check_send_message(sender: UserProfile, client: Client, message_type_name: s message = check_message(sender, client, addressee, message_content, realm, forged, forged_timestamp, - forwarder_user_profile, local_id, sender_queue_id) + forwarder_user_profile, local_id, sender_queue_id, + widget_content) return do_send_messages([message])[0] def check_schedule_message(sender: UserProfile, client: Client, @@ -1939,7 +1942,8 @@ def check_message(sender: UserProfile, client: Client, addressee: Addressee, forged_timestamp: Optional[float]=None, forwarder_user_profile: Optional[UserProfile]=None, local_id: Optional[str]=None, - sender_queue_id: Optional[str]=None) -> Dict[str, Any]: + sender_queue_id: Optional[str]=None, + widget_content: Optional[str]=None) -> Dict[str, Any]: stream = None message_content = message_content_raw.rstrip() @@ -2037,8 +2041,19 @@ def check_message(sender: UserProfile, client: Client, addressee: Addressee, if id is not None: return {'message': id} + if widget_content is not None: + try: + widget_content = ujson.loads(widget_content) + except Exception: + raise JsonableError(_('Widgets: API programmer sent invalid JSON content')) + + error_msg = check_widget_content(widget_content) + if error_msg: + raise JsonableError(_('Widgets: %s') % (error_msg,)) + return {'message': message, 'stream': stream, 'local_id': local_id, - 'sender_queue_id': sender_queue_id, 'realm': realm} + 'sender_queue_id': sender_queue_id, 'realm': realm, + 'widget_content': widget_content} def _internal_prep_message(realm: Realm, sender: UserProfile, diff --git a/zerver/lib/validator.py b/zerver/lib/validator.py index 59e6ff636d..b0e19930b8 100644 --- a/zerver/lib/validator.py +++ b/zerver/lib/validator.py @@ -237,3 +237,48 @@ def validate_choice_field(var_name: str, field_data: str, value: object) -> None if value not in field_data_dict: msg = _("'{value}' is not a valid choice for '{field_name}'.") return msg.format(value=value, field_name=var_name) + +def check_widget_content(widget_content: object) -> Optional[str]: + if not isinstance(widget_content, dict): + return 'widget_content is not a dict' + + if 'widget_type' not in widget_content: + return 'widget_type is not in widget_content' + + if 'extra_data' not in widget_content: + return 'extra_data is not in widget_content' + + widget_type = widget_content['widget_type'] + extra_data = widget_content['extra_data'] + + if not isinstance(extra_data, dict): + return 'extra_data is not a dict' + + if widget_type == 'zform': + + if 'type' not in extra_data: + return 'zform is missing type field' + + if extra_data['type'] == 'choices': + check_choices = check_list( + check_dict([ + ('short_name', check_string), + ('long_name', check_string), + ('reply', check_string), + ]), + ) + + checker = check_dict([ + ('heading', check_string), + ('choices', check_choices), + ]) + + msg = checker('extra_data', extra_data) + if msg: + return msg + + return None + + return 'unknown zform type: ' + extra_data['type'] + + return 'unknown widget type: ' + widget_type diff --git a/zerver/lib/widget.py b/zerver/lib/widget.py index f3837c0dd0..8164327f98 100644 --- a/zerver/lib/widget.py +++ b/zerver/lib/widget.py @@ -46,6 +46,13 @@ def do_widget_post_save_actions(message: MutableMapping[str, Any]) -> None: if content in ['/poll', '/tictactoe']: widget_type = content[1:] + widget_content = message.get('widget_content') + if widget_content is not None: + # Note that we validate this data in check_message, + # so we can trust it here. + widget_type = widget_content['widget_type'] + extra_data = widget_content['extra_data'] + if widget_type: content = dict( widget_type=widget_type, diff --git a/zerver/tests/test_widgets.py b/zerver/tests/test_widgets.py new file mode 100644 index 0000000000..1efc059568 --- /dev/null +++ b/zerver/tests/test_widgets.py @@ -0,0 +1,56 @@ +from typing import Dict, Any + +from zerver.lib.test_classes import ZulipTestCase + +from zerver.lib.validator import check_widget_content + +class WidgetContentTestCase(ZulipTestCase): + def test_validation(self) -> None: + def assert_error(obj: object, msg: str) -> None: + self.assertEqual(check_widget_content(obj), msg) + + assert_error(5, + 'widget_content is not a dict') + + assert_error({}, + 'widget_type is not in widget_content') + + assert_error(dict(widget_type='whatever'), + 'extra_data is not in widget_content') + + assert_error(dict(widget_type='zform', extra_data=4), + 'extra_data is not a dict') + + assert_error(dict(widget_type='bogus', extra_data={}), + 'unknown widget type: bogus') + + extra_data = dict() # type: Dict[str, Any] + obj = dict(widget_type='zform', extra_data=extra_data) + + assert_error(obj, 'zform is missing type field') + + extra_data['type'] = 'bogus' + assert_error(obj, 'unknown zform type: bogus') + + extra_data['type'] = 'choices' + assert_error(obj, 'heading key is missing from extra_data') + + extra_data['heading'] = 'whatever' + assert_error(obj, 'choices key is missing from extra_data') + + extra_data['choices'] = 99 + assert_error(obj, 'extra_data["choices"] is not a list') + + extra_data['choices'] = [99] + assert_error(obj, 'extra_data["choices"][0] is not a dict') + + extra_data['choices'] = [ + dict(long_name='foo', reply='bar'), + ] + assert_error(obj, 'short_name key is missing from extra_data["choices"][0]') + + extra_data['choices'] = [ + dict(short_name='a', long_name='foo', reply='bar'), + ] + + self.assertEqual(check_widget_content(obj), None) diff --git a/zerver/views/messages.py b/zerver/views/messages.py index af6a7f26d3..e903544d2e 100644 --- a/zerver/views/messages.py +++ b/zerver/views/messages.py @@ -1153,6 +1153,7 @@ def send_message_backend(request: HttpRequest, user_profile: UserProfile, topic_name: Optional[str]=REQ('subject', converter=lambda x: x.strip(), default=None), message_content: str=REQ('content'), + widget_content: Optional[str]=REQ(default=None), realm_str: Optional[str]=REQ('realm_str', default=None), local_id: Optional[str]=REQ(default=None), queue_id: Optional[str]=REQ(default=None), @@ -1217,7 +1218,8 @@ def send_message_backend(request: HttpRequest, user_profile: UserProfile, topic_name, message_content, forged=forged, forged_timestamp = request.POST.get('time'), forwarder_user_profile=user_profile, realm=realm, - local_id=local_id, sender_queue_id=queue_id) + local_id=local_id, sender_queue_id=queue_id, + widget_content=widget_content) return json_success({"id": ret}) def fill_edit_history_entries(message_history: List[Dict[str, Any]], message: Message) -> None: