Repository: zulip/zulip

Issue:
integrations: Add a webhook integration for Front.
This would be a great project for a new contributor.
We have a guide for writing webhook integrations at http://zulip.readthedocs.io/en/latest/integration-guide.html#webhook-integrations.

Front is at frontapp.com.


Discussion:
@zulipbot claim
Hello @dgarvit, you claimed this issue to work on it, but this issue and any referenced pull requests haven't been updated for 7 days. Are you still working on this issue?

If so, please update this issue by leaving a comment on this issue to let me know that you're still working on it. Otherwise, I'll automatically remove you from this issue in 3 days.

If you've decided to work on something else, simply comment `@zulipbot abandon` so that someone else can claim it and continue from where you left off.

Thank you for your valuable contributions to Zulip!
@zulipbot abandon
Hey @rishig i want to do this ...but it's my first time so i need a little guidance.I know python and read about webhook integration from the docs..I'm hoping this can be my first contribution to zulip as you wrote that it's good for beginners..
@zulipbot claim
Welcome to Zulip, @Rishabh570! We just sent you an invite to collaborate on this repository at https://github.com/zulip/zulip/invitations. Please accept this invite in order to claim this issue and begin a fun, rewarding experience contributing to Zulip!

Here's some tips to get you off to a good start:
* Join me on the [Zulip developers' server](https://chat.zulip.org), to get help, chat about this issue, and meet the other developers.
* Sign the [Dropbox Contributor License Agreement](https://opensource.dropbox.com/cla/), so that Zulip can use your code.
* [Unwatch this repository](https://help.github.com/articles/unwatching-repositories/), so that you don't get 100 emails a day.

As you work on this issue, you'll also want to refer to the [Zulip code contribution guide](http://zulip.readthedocs.io/en/latest/index.html#code-contribution-guide), as well as the rest of the developer documentation on that site.


See you on the other side (that is, the pull request side)!
Great, and welcome! https://github.com/zulip/zulip-gci/blob/master/tasks/webhook-integrations.md might be helpful as well.


I'm not getting exactly what info i need to show up in my view.py...
I'd recommend starting with the hello world example in the link I posted. Let's also move this to chat.zulip.org #"development help" stream, where more people will see it.
I've been through hello world example...but the fixture there was small and concise so it was pretty understandable that which info is to be extracted from it...but in case of front the json file is very big as compared to helloworld and i'm being confused...
Thnx BTW...
@Rishabh570 Are you still working on it?

@sagar-kalra Yes...sorry for delay, got some error to be fixed...
Hello @Rishabh570, you claimed this issue to work on it, but this issue and any referenced pull requests haven't been updated for 7 days. Are you still working on this issue?

If so, please update this issue by leaving a comment on this issue to let me know that you're still working on it. Otherwise, I'll automatically remove you from this issue in 3 days.

If you've decided to work on something else, simply comment `@zulipbot abandon` so that someone else can claim it and continue from where you left off.

Thank you for your valuable contributions to Zulip!
Yes...working on it..about two or three days i need at max cuz my exams are coming.
Hello @Rishabh570, you claimed this issue to work on it, but this issue and any referenced pull requests haven't been updated for 7 days. Are you still working on this issue?

If so, please update this issue by leaving a comment on this issue to let me know that you're still working on it. Otherwise, I'll automatically remove you from this issue in 3 days.

If you've decided to work on something else, simply comment `@zulipbot abandon` so that someone else can claim it and continue from where you left off.

Thank you for your valuable contributions to Zulip!
yes...working on it...opened up a WIP PR #7245 ..it will done within this week most probably !!!
Hello @Rishabh570, you claimed this issue to work on it, but this issue and any referenced pull requests haven't been updated for 10 days. Are you still working on this issue?

If so, please update this issue by leaving a comment on this issue to let me know that you're still working on it. Otherwise, I'll automatically remove you from this issue in 4 days.

If you've decided to work on something else, simply comment `@zulipbot abandon` so that someone else can claim it and continue from where you left off.

Thank you for your valuable contributions to Zulip!

<!-- inactiveWarning -->
Working...Actually delay is happening due to my exams in 1st and 2nd week of Dec..But will try to manage time for it..!!!
Hello @Rishabh570, you claimed this issue to work on it, but this issue and any referenced pull requests haven't been updated for 10 days. Are you still working on this issue?

If so, please update this issue by leaving a comment on this issue to let me know that you're still working on it. Otherwise, I'll automatically remove you from this issue in 4 days.

If you've decided to work on something else, simply comment `@zulipbot abandon` so that someone else can claim it and continue from where you left off.

Thank you for your valuable contributions to Zulip!

<!-- inactiveWarning -->
Little busy...but will get it done soon...!
I'm done working on it...PR is #7245  
Hello @Rishabh570, you have been unassigned from this issue because you have not updated this issue or any referenced pull requests for over 14 days.

You can reclaim this issue or claim any other issue by commenting `@zulipbot claim` on that issue.

Thanks for your contributions, and hope to see you again soon!
@zulipbot claim
Hello @Rishabh570, it looks like you've currently claimed 1 issue in this repository. We encourage new contributors to focus their efforts on at most 1 issue at a time, so please complete your work on your other claimed issues before trying to claim this issue again.

We look forward to your valuable contributions!
@zulipbot claim

Welcome to Zulip, @RavicharanN! We just sent you an invite to collaborate on this repository at https://github.com/zulip/zulip/invitations. Please accept this invite in order to claim this issue and begin a fun, rewarding experience contributing to Zulip!

Here's some tips to get you off to a good start:
* Join me on the [Zulip developers' server](https://chat.zulip.org), to get help, chat about this issue, and meet the other developers.
* Sign the [Dropbox Contributor License Agreement](https://opensource.dropbox.com/cla/), so that Zulip can use your code.
* [Unwatch this repository](https://help.github.com/articles/unwatching-repositories/), so that you don't get 100 emails a day.

As you work on this issue, you'll also want to refer to the [Zulip code contribution guide](https://zulip.readthedocs.io/en/latest/contributing/index.html), as well as the rest of the developer documentation on that site.

See you on the other side (that is, the pull request side)!
Hey @rishig , looks like the webhook integration guidelines have been removed. Can you provide me with an alternative source?
@RavicharanN  You might want to read this to proceed with the webhook integrations : https://zulip.readthedocs.io/en/1.7.1/webhook-walkthrough.html
and PR for this issue is #7245 

Resolving patch:
diff --git a/zerver/lib/integrations.py b/zerver/lib/integrations.py
--- a/zerver/lib/integrations.py
+++ b/zerver/lib/integrations.py
@@ -294,6 +294,7 @@ def __init__(self, name: str, *args: Any, **kwargs: Any) -> None:
     ),
     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
diff --git a/zerver/webhooks/front/view.py b/zerver/webhooks/front/view.py
new file mode 100644
--- /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()


Test patch:
diff --git a/zerver/webhooks/front/tests.py b/zerver/webhooks/front/tests.py
new file mode 100644
--- /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")