From 822dfc6a34d1d53b314a62b4028b0adb52c5f0fe Mon Sep 17 00:00:00 2001 From: Alena Volkova Date: Thu, 8 Feb 2018 13:46:15 -0500 Subject: [PATCH] integrations: Add webhook code, API endpoint, and tests for Front. --- static/images/integrations/logos/front.svg | Bin 0 -> 795 bytes zerver/lib/integrations.py | 1 + zerver/webhooks/front/__init__.py | 0 zerver/webhooks/front/tests.py | 282 +++++++++++++++++++++ zerver/webhooks/front/view.py | 188 ++++++++++++++ 5 files changed, 471 insertions(+) create mode 100644 static/images/integrations/logos/front.svg create mode 100644 zerver/webhooks/front/__init__.py create mode 100644 zerver/webhooks/front/tests.py create mode 100644 zerver/webhooks/front/view.py diff --git a/static/images/integrations/logos/front.svg b/static/images/integrations/logos/front.svg new file mode 100644 index 0000000000000000000000000000000000000000..c08886367f924d4a933e0a3243dcb033352a36c4 GIT binary patch literal 795 zcmXX^O>ZJG4E-xs_8en7aXwa1sjO7(p}p*Fj|eCr(Jg{esru{p1**{`W95zChcmx? z?C5&fO#4MJ7YVfA9(MZ)m1+<4aoAp$@1KhxBxGL5vo9o`q=^X0Jli#I&XPpfH9n?-mr?X1;E_OU8U zTSu;`kVNI23tQ>Yh^yykO+sp{O;?E}Io;QxOk*Z_R_WYzU~d4s0nm9o(a_iP?^L)Y za5Ve?isGEIM`N?h-PeB&K30IyEV?u))G_V<~f30A*W<6DaV9OY6PoWeuUech9#^w zOX&I?-Pc<*fLf(w+;dqYrrt<1Y=TI*6+$yB{$04UkB})hRxdFNJQ_8G(>=D%Vh{)8 zhGY}xgdwous5VXlW$^AAYexjJDR3F_))vhGcB+g3Vje1RWrS<*(Hm?)6bu-W*Pak& zLEqjrLIs#uRi6_iDWc+{?FdCu8x>5owACn-?(01Q#4DBLhO;u+t#?3iNt79v9)q(^FZNWtct8#}$6~5AiFk!vFvP literal 0 HcmV?d00001 diff --git a/zerver/lib/integrations.py b/zerver/lib/integrations.py index da2db9d62d..2f448047d2 100644 --- a/zerver/lib/integrations.py +++ b/zerver/lib/integrations.py @@ -294,6 +294,7 @@ WEBHOOK_INTEGRATIONS = [ ), WebhookIntegration('dropbox', ['productivity'], display_name='Dropbox'), WebhookIntegration('freshdesk', ['customer-support']), + WebhookIntegration('front', ['customer-support'], display_name='Front'), GithubIntegration( 'github', ['version-control'], diff --git a/zerver/webhooks/front/__init__.py b/zerver/webhooks/front/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/zerver/webhooks/front/tests.py b/zerver/webhooks/front/tests.py new file mode 100644 index 0000000000..04dff20e17 --- /dev/null +++ b/zerver/webhooks/front/tests.py @@ -0,0 +1,282 @@ +from typing import Text +import ujson + +from zerver.lib.test_classes import WebhookTestCase + +class FrontHookTests(WebhookTestCase): + STREAM_NAME = 'front' + URL_TEMPLATE = "/api/v1/external/front?&api_key={api_key}" + FIXTURE_DIR_NAME = 'front' + + def _test_no_message_data(self, fixture_name: Text) -> None: + payload = self.get_body(fixture_name) + payload_json = ujson.loads(payload) + del payload_json['conversation']['subject'] + result = self.client_post(self.url, ujson.dumps(payload_json), + content_type="application/x-www-form-urlencoded") + + self.assert_json_error(result, "Missing required data") + + def _test_no_source_name(self, fixture_name: Text) -> None: + payload = self.get_body(fixture_name) + payload_json = ujson.loads(payload) + del payload_json['source']['data']['first_name'] + result = self.client_post(self.url, ujson.dumps(payload_json), + content_type="application/x-www-form-urlencoded") + + self.assert_json_error(result, "Missing required data") + + def _test_no_target_name(self, fixture_name: Text) -> None: + payload = self.get_body(fixture_name) + payload_json = ujson.loads(payload) + del payload_json['target']['data']['first_name'] + result = self.client_post(self.url, ujson.dumps(payload_json), + content_type="application/x-www-form-urlencoded") + + self.assert_json_error(result, "Missing required data") + + def _test_no_comment(self, fixture_name: Text) -> None: + payload = self.get_body(fixture_name) + payload_json = ujson.loads(payload) + del payload_json['target']['data']['body'] + result = self.client_post(self.url, ujson.dumps(payload_json), + content_type="application/x-www-form-urlencoded") + + self.assert_json_error(result, "Missing required data") + + def _test_no_tag(self, fixture_name: Text) -> None: + payload = self.get_body(fixture_name) + payload_json = ujson.loads(payload) + del payload_json['target']['data']['name'] + result = self.client_post(self.url, ujson.dumps(payload_json), + content_type="application/x-www-form-urlencoded") + + self.assert_json_error(result, "Missing required data") + + def test_no_event_type(self) -> None: + payload = self.get_body('1_conversation_assigned_outbound') + payload_json = ujson.loads(payload) + del payload_json['type'] + result = self.client_post(self.url, ujson.dumps(payload_json), + content_type="application/x-www-form-urlencoded") + + self.assert_json_error(result, "Missing required data") + + def test_no_conversation_id(self) -> None: + payload = self.get_body('1_conversation_assigned_outbound') + payload_json = ujson.loads(payload) + del payload_json['conversation']['id'] + result = self.client_post(self.url, ujson.dumps(payload_json), + content_type="application/x-www-form-urlencoded") + + self.assert_json_error(result, "Missing required data") + + # Scenario 1: Conversation starts from an outbound message. + + # Conversation automatically assigned to a teammate who started it. + def test_conversation_assigned_outbound(self) -> None: + expected_subject = 'cnv_keo696' + expected_message = "**Leela Turanga** assigned themselves." + + self.send_and_test_stream_message('1_conversation_assigned_outbound', + expected_subject, + expected_message, + content_type="application/x-www-form-urlencoded") + + def test_outbound_message(self) -> None: + expected_subject = 'cnv_keo696' + expected_message = "[Outbound message](https://app.frontapp.com/open/msg_1176ie2) " \ + "from **support@planet-express.com** " \ + "to **calculon@momsbot.com**.\n" \ + "```quote\n*Subject*: Your next delivery is on Epsilon 96Z\n```" + + self.send_and_test_stream_message('2_outbound_message', + expected_subject, + expected_message, + content_type="application/x-www-form-urlencoded") + + def test_outbound_message_error(self) -> None: + self._test_no_message_data('2_outbound_message') + + def test_conversation_archived(self) -> None: + expected_subject = 'cnv_keo696' + expected_message = "Archived by **Leela Turanga**." + + self.send_and_test_stream_message('3_conversation_archived', + expected_subject, + expected_message, + content_type="application/x-www-form-urlencoded") + + def test_conversation_archived_error(self) -> None: + self._test_no_source_name('3_conversation_archived') + + def test_conversation_reopened(self) -> None: + expected_subject = 'cnv_keo696' + expected_message = "Reopened by **Leela Turanga**." + + self.send_and_test_stream_message('4_conversation_reopened', + expected_subject, + expected_message, + content_type="application/x-www-form-urlencoded") + + def test_conversation_reopened_error(self) -> None: + self._test_no_source_name('4_conversation_reopened') + + def test_conversation_deleted(self) -> None: + expected_subject = 'cnv_keo696' + expected_message = "Deleted by **Leela Turanga**." + + self.send_and_test_stream_message('5_conversation_deleted', + expected_subject, + expected_message, + content_type="application/x-www-form-urlencoded") + + def test_conversation_deleted_error(self) -> None: + self._test_no_source_name('5_conversation_deleted') + + def test_conversation_restored(self) -> None: + expected_subject = 'cnv_keo696' + expected_message = "Restored by **Leela Turanga**." + + self.send_and_test_stream_message('6_conversation_restored', + expected_subject, + expected_message, + content_type="application/x-www-form-urlencoded") + + def test_conversation_restored_error(self) -> None: + self._test_no_source_name('6_conversation_restored') + + def test_conversation_unassigned(self) -> None: + expected_subject = 'cnv_keo696' + expected_message = "Unassined by **Leela Turanga**." + + self.send_and_test_stream_message('7_conversation_unassigned', + expected_subject, + expected_message, + content_type="application/x-www-form-urlencoded") + + def test_conversation_unassigned_error(self) -> None: + self._test_no_source_name('7_conversation_unassigned') + + def test_mention_all(self) -> None: + expected_subject = 'cnv_keo696' + expected_message = "**Leela Turanga** left a comment:\n" \ + "```quote\n@all Could someone else take this?\n```" + + self.send_and_test_stream_message('8_mention_all', + expected_subject, + expected_message, + content_type="application/x-www-form-urlencoded") + + # Scenario 2: Conversation starts from an inbound message. + + def test_inbound_message(self) -> None: + expected_subject = 'cnv_keocka' + expected_message = "[Inbound message](https://app.frontapp.com/open/msg_1176r8y) " \ + "from **calculon@momsbot.com** " \ + "to **support@planet-express.com**.\n" \ + "```quote\n*Subject*: Being a robot is great, but...\n```" + + self.send_and_test_stream_message('9_inbound_message', + expected_subject, + expected_message, + content_type="application/x-www-form-urlencoded") + + def test_inbound_message_error(self) -> None: + self._test_no_message_data('9_inbound_message') + + def test_conversation_tagged(self) -> None: + expected_subject = 'cnv_keocka' + expected_message = "**Leela Turanga** added tag **Urgent**." + + self.send_and_test_stream_message('10_conversation_tagged', + expected_subject, + expected_message, + content_type="application/x-www-form-urlencoded") + + def test_conversation_tagged_error(self) -> None: + self._test_no_tag('10_conversation_tagged') + + # Conversation automatically assigned to a teammate who replied to it. + def test_conversation_assigned_reply(self) -> None: + expected_subject = 'cnv_keocka' + expected_message = "**Leela Turanga** assigned themselves." + + self.send_and_test_stream_message('11_conversation_assigned_reply', + expected_subject, + expected_message, + content_type="application/x-www-form-urlencoded") + + def test_outbound_reply(self) -> None: + expected_subject = 'cnv_keocka' + expected_message = "[Outbound reply](https://app.frontapp.com/open/msg_1176ryy) " \ + "from **support@planet-express.com** " \ + "to **calculon@momsbot.com**." + + self.send_and_test_stream_message('12_outbound_reply', + expected_subject, + expected_message, + content_type="application/x-www-form-urlencoded") + + def test_outbound_reply_error(self) -> None: + self._test_no_message_data('12_outbound_reply') + + def test_conversation_untagged(self) -> None: + expected_subject = 'cnv_keocka' + expected_message = "**Leela Turanga** removed tag **Urgent**." + + self.send_and_test_stream_message('13_conversation_untagged', + expected_subject, + expected_message, + content_type="application/x-www-form-urlencoded") + + def test_conversation_untagged_error(self) -> None: + self._test_no_tag('13_conversation_untagged') + + def test_mention(self) -> None: + expected_subject = 'cnv_keocka' + expected_message = "**Leela Turanga** left a comment:\n" \ + "```quote\n@bender Could you take it from here?\n```" + + self.send_and_test_stream_message('14_mention', + expected_subject, + expected_message, + content_type="application/x-www-form-urlencoded") + + def test_comment(self) -> None: + expected_subject = 'cnv_keocka' + expected_message = "**Bender Rodriguez** left a comment:\n" \ + "```quote\nSure.\n```" + + self.send_and_test_stream_message('15_comment', + expected_subject, + expected_message, + content_type="application/x-www-form-urlencoded") + + def test_comment_error(self) -> None: + self._test_no_comment('15_comment') + + # Conversation manually assigned to another teammate. + def test_conversation_assigned(self) -> None: + expected_subject = 'cnv_keocka' + expected_message = "**Leela Turanga** assigned **Bender Rodriguez**." + + self.send_and_test_stream_message('16_conversation_assigned', + expected_subject, + expected_message, + content_type="application/x-www-form-urlencoded") + + def test_conversation_assigned_error(self) -> None: + self._test_no_target_name('16_conversation_assigned') + + def test_unknown_webhook_request(self) -> None: + payload = self.get_body('16_conversation_assigned') + payload_json = ujson.loads(payload) + payload_json['type'] = 'qwerty' + result = self.client_post(self.url, ujson.dumps(payload_json), + content_type="application/x-www-form-urlencoded") + + self.assert_json_error(result, "Unknown webhook request") + + def get_body(self, fixture_name: Text) -> Text: + return self.fixture_data('front', fixture_name, file_type="json") diff --git a/zerver/webhooks/front/view.py b/zerver/webhooks/front/view.py new file mode 100644 index 0000000000..8f757bccef --- /dev/null +++ b/zerver/webhooks/front/view.py @@ -0,0 +1,188 @@ +from typing import Any, Dict, Optional, Text, Tuple + +from django.http import HttpRequest, HttpResponse +from django.utils.translation import ugettext as _ + +from zerver.decorator import api_key_only_webhook_view +from zerver.lib.actions import check_send_stream_message +from zerver.lib.request import REQ, has_request_variables +from zerver.lib.response import json_error, json_success +from zerver.models import UserProfile + +def get_message_data(payload: Dict[Text, Any]) -> Optional[Tuple[Text, Text, Text, Text]]: + try: + link = "https://app.frontapp.com/open/" + payload['target']['data']['id'] + outbox = payload['conversation']['recipient']['handle'] + inbox = payload['source']['data'][0]['address'] + subject = payload['conversation']['subject'] + except KeyError: + return None + + return link, outbox, inbox, subject + +def get_source_name(payload: Dict[Text, Any]) -> Optional[Text]: + try: + first_name = payload['source']['data']['first_name'] + last_name = payload['source']['data']['last_name'] + except KeyError: + return None + + return "%s %s" % (first_name, last_name) + +def get_target_name(payload: Dict[Text, Any]) -> Optional[Text]: + try: + first_name = payload['target']['data']['first_name'] + last_name = payload['target']['data']['last_name'] + except KeyError: + return None + + return "%s %s" % (first_name, last_name) + +def get_comment(payload: Dict[Text, Any]) -> Optional[Text]: + try: + comment = payload['target']['data']['body'] + except KeyError: + return None + + return comment + +def get_tag(payload: Dict[Text, Any]) -> Optional[Text]: + try: + tag = payload['target']['data']['name'] + except KeyError: + return None + + return tag + +@api_key_only_webhook_view('Front') +@has_request_variables +def api_front_webhook(request: HttpRequest, user_profile: UserProfile, + payload: Dict[Text, Any]=REQ(argument_type='body'), + stream: Text=REQ(default='front'), + topic: Optional[Text]=REQ(default='cnv_id')) -> HttpResponse: + try: + event_type = payload['type'] + conversation_id = payload['conversation']['id'] + except KeyError: + return json_error(_("Missing required data")) + + # Each topic corresponds to a separate conversation in Front. + topic = conversation_id + + # Inbound message + if event_type == 'inbound': + message_data = get_message_data(payload) + if not message_data: + return json_error(_("Missing required data")) + + link, outbox, inbox, subject = message_data + body = "[Inbound message]({link}) from **{outbox}** to **{inbox}**.\n" \ + "```quote\n*Subject*: {subject}\n```" \ + .format(link=link, outbox=outbox, inbox=inbox, subject=subject) + + # Outbound message + elif event_type == 'outbound': + message_data = get_message_data(payload) + if not message_data: + return json_error(_("Missing required data")) + + link, outbox, inbox, subject = message_data + body = "[Outbound message]({link}) from **{inbox}** to **{outbox}**.\n" \ + "```quote\n*Subject*: {subject}\n```" \ + .format(link=link, inbox=inbox, outbox=outbox, subject=subject) + + # Outbound reply + elif event_type == 'out_reply': + message_data = get_message_data(payload) + if not message_data: + return json_error(_("Missing required data")) + + link, outbox, inbox, subject = message_data + body = "[Outbound reply]({link}) from **{inbox}** to **{outbox}**." \ + .format(link=link, inbox=inbox, outbox=outbox) + + # Comment or mention + elif event_type == 'comment' or event_type == 'mention': + name, comment = get_source_name(payload), get_comment(payload) + if not (name and comment): + return json_error(_("Missing required data")) + + body = "**{name}** left a comment:\n```quote\n{comment}\n```" \ + .format(name=name, comment=comment) + + # Conversation assigned + elif event_type == 'assign': + source_name = get_source_name(payload) + target_name = get_target_name(payload) + + if not (source_name and target_name): + return json_error(_("Missing required data")) + + if source_name == target_name: + body = "**{source_name}** assigned themselves." \ + .format(source_name=source_name) + else: + body = "**{source_name}** assigned **{target_name}**." \ + .format(source_name=source_name, target_name=target_name) + + # Conversation unassigned + elif event_type == 'unassign': + name = get_source_name(payload) + if not name: + return json_error(_("Missing required data")) + + body = "Unassined by **{name}**.".format(name=name) + + # Conversation archived + elif event_type == 'archive': + name = get_source_name(payload) + if not name: + return json_error(_("Missing required data")) + + body = "Archived by **{name}**.".format(name=name) + + # Conversation reopened + elif event_type == 'reopen': + name = get_source_name(payload) + if not name: + return json_error(_("Missing required data")) + + body = "Reopened by **{name}**.".format(name=name) + + # Conversation deleted + elif event_type == 'trash': + name = get_source_name(payload) + if not name: + return json_error(_("Missing required data")) + + body = "Deleted by **{name}**.".format(name=name) + + # Conversation restored + elif event_type == 'restore': + name = get_source_name(payload) + if not name: + return json_error(_("Missing required data")) + + body = "Restored by **{name}**.".format(name=name) + + # Conversation tagged + elif event_type == 'tag': + name, tag = get_source_name(payload), get_tag(payload) + if not (name and tag): + return json_error(_("Missing required data")) + + body = "**{name}** added tag **{tag}**.".format(name=name, tag=tag) + + # Conversation untagged + elif event_type == 'untag': + name, tag = get_source_name(payload), get_tag(payload) + if not (name and tag): + return json_error(_("Missing required data")) + + body = "**{name}** removed tag **{tag}**.".format(name=name, tag=tag) + else: + return json_error(_("Unknown webhook request")) + + check_send_stream_message(user_profile, request.client, stream, topic, body) + + return json_success()