diff --git a/static/images/integrations/facebook/001.png b/static/images/integrations/facebook/001.png new file mode 100644 index 0000000000..186635ea60 Binary files /dev/null and b/static/images/integrations/facebook/001.png differ diff --git a/static/images/integrations/facebook/002.png b/static/images/integrations/facebook/002.png new file mode 100644 index 0000000000..4a3c9cee2a Binary files /dev/null and b/static/images/integrations/facebook/002.png differ diff --git a/static/images/integrations/facebook/003.png b/static/images/integrations/facebook/003.png new file mode 100644 index 0000000000..501360f8bd Binary files /dev/null and b/static/images/integrations/facebook/003.png differ diff --git a/static/images/integrations/facebook/004.png b/static/images/integrations/facebook/004.png new file mode 100644 index 0000000000..5062cd5d9d Binary files /dev/null and b/static/images/integrations/facebook/004.png differ diff --git a/static/images/integrations/facebook/005.png b/static/images/integrations/facebook/005.png new file mode 100644 index 0000000000..e549a2ecf3 Binary files /dev/null and b/static/images/integrations/facebook/005.png differ diff --git a/static/images/integrations/facebook/006.png b/static/images/integrations/facebook/006.png new file mode 100644 index 0000000000..8c9ba52ccf Binary files /dev/null and b/static/images/integrations/facebook/006.png differ diff --git a/static/images/integrations/facebook/007.png b/static/images/integrations/facebook/007.png new file mode 100644 index 0000000000..8c140ab1b7 Binary files /dev/null and b/static/images/integrations/facebook/007.png differ diff --git a/static/images/integrations/facebook/008.png b/static/images/integrations/facebook/008.png new file mode 100644 index 0000000000..672b6e4bc1 Binary files /dev/null and b/static/images/integrations/facebook/008.png differ diff --git a/static/images/integrations/logos/facebook.svg b/static/images/integrations/logos/facebook.svg new file mode 100644 index 0000000000..3dd9c97d7c --- /dev/null +++ b/static/images/integrations/logos/facebook.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/zerver/lib/integrations.py b/zerver/lib/integrations.py index 7db8f9fbb4..6313ca854c 100644 --- a/zerver/lib/integrations.py +++ b/zerver/lib/integrations.py @@ -368,7 +368,8 @@ WEBHOOK_INTEGRATIONS = [ WebhookIntegration('zapier', ['meta-integration']), WebhookIntegration('zendesk', ['customer-support']), WebhookIntegration('gci', ['misc'], display_name='Google Code-in', - stream_name='gci') + stream_name='gci'), + WebhookIntegration('facebook', ['communication'], display_name='Facebook') ] # type: List[WebhookIntegration] INTEGRATIONS = { diff --git a/zerver/webhooks/facebook/__init__.py b/zerver/webhooks/facebook/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/zerver/webhooks/facebook/doc.md b/zerver/webhooks/facebook/doc.md new file mode 100644 index 0000000000..7c24d6fd52 --- /dev/null +++ b/zerver/webhooks/facebook/doc.md @@ -0,0 +1,75 @@ +{!create-stream.md!} + +Next, on your {{ settings_html|safe }}, +[create a bot](/help/add-a-bot-or-integration) for +{{ integration_display_name }}. Make sure that you select +**Incoming webhook** as the **Bot type**: + +![](/static/images/help/bot_types.png) + +The API key for an incoming webhook bot cannot be used to read messages out +of Zulip. Thus, using an incoming webhook bot lowers the security risk of +exposing the bot's API key to a third-party service. + +Construct the URL for the {{ integration_display_name }} +bot using the bot's API key and the desired stream name: + +`{{ api_url }}{{ integration_url }}?api_key=abcdefgh&stream={{ recommended_stream_name }}&token=sampletoken` + +Modify the parameters of the URL above, where `api_key` is the API key +of your Zulip bot, and `stream` is the stream name you want the +notifications sent to. + +`token` is an arbitrary string of your choosing that can be used to confirm to your + server that the request is valid. This string will be included in Facebook's + incoming payloads each time they send your server a verification request. + +{!append-stream-name.md!} + +### Configuring the webhook + +Sign In to the following URL: + +Next, click on **+ Add a New App** button. + +![](/static/images/integrations/facebook/001.png) + +Then, fill in the following form to create a new Facebook app: + +![](/static/images/integrations/facebook/002.png) + +Next, under **Webhooks**, click on **Set up**: + +![](/static/images/integrations/facebook/003.png) + +Choose a category for the webhook: + +![](/static/images/integrations/facebook/004.png) + +This guide explains how to subscribe to a "feed" in the **User** category. + +Select the **User** category, and click on **Subscribe to this topic**. +Fill in the **Edit User Subscription** form as follows: + +1. **Callback URL**: enter the webhook URL created above. +2. **Verify Token**: enter the token you chose above. For instance, in this example you may enter **sampletoken** +3. Activate the **Include Values** option. +4. Click on **Verify and Save**. + +The resulting form would look like: + +![](/static/images/integrations/facebook/005.png) + +Finally, click **Subscribe** and **Test** in the **feed** row, like so: + +![](/static/images/integrations/facebook/006.png) + +Click on **Send to My Server** and a test message will be sent to your Zulip server. + +![](/static/images/integrations/facebook/007.png) + +{!congrats.md!} + +![](/static/images/integrations/facebook/008.png) + +**This integration is not created by, affiliated with, or supported by Facebook, Inc.** diff --git a/zerver/webhooks/facebook/fixtures/page_conversation.json b/zerver/webhooks/facebook/fixtures/page_conversations.json similarity index 100% rename from zerver/webhooks/facebook/fixtures/page_conversation.json rename to zerver/webhooks/facebook/fixtures/page_conversations.json diff --git a/zerver/webhooks/facebook/fixtures/permissions_ads_management.json b/zerver/webhooks/facebook/fixtures/permissions_ads_management.json index 4fcedc3158..350128ef56 100644 --- a/zerver/webhooks/facebook/fixtures/permissions_ads_management.json +++ b/zerver/webhooks/facebook/fixtures/permissions_ads_management.json @@ -1,13 +1,22 @@ { - "entry":[ - { - "time":1514912190, - "id":"0", - "changed_fields":[ - "ads_management" - ], - "uid":"0" - } - ], - "object":"permissions" + "entry": [ + { + "changes": [ + { + "field": "ads_management", + "value": { + "target_ids": [ + "123123123123123", + "321321321321321" + ], + "verb": "granted" + } + } + ], + "id": "0", + "time": 1515252883, + "uid": "0" + } + ], + "object": "permissions" } diff --git a/zerver/webhooks/facebook/fixtures/permissions_manage_pages.json b/zerver/webhooks/facebook/fixtures/permissions_manage_pages.json index 7f33be3d3b..307ebd24f2 100644 --- a/zerver/webhooks/facebook/fixtures/permissions_manage_pages.json +++ b/zerver/webhooks/facebook/fixtures/permissions_manage_pages.json @@ -1,13 +1,22 @@ { - "entry":[ - { - "time":1514912232, - "id":"0", - "changed_fields":[ - "manage_pages" - ], - "uid":"0" - } - ], - "object":"permissions" + "entry": [ + { + "changes": [ + { + "field": "manage_pages", + "value": { + "target_ids": [ + "123123123123123", + "321321321321321" + ], + "verb": "granted" + } + } + ], + "id": "0", + "time": 1515254831, + "uid": "0" + } + ], + "object": "permissions" } diff --git a/zerver/webhooks/facebook/fixtures/users_email.json b/zerver/webhooks/facebook/fixtures/user_email.json similarity index 100% rename from zerver/webhooks/facebook/fixtures/users_email.json rename to zerver/webhooks/facebook/fixtures/user_email.json diff --git a/zerver/webhooks/facebook/fixtures/users_feed.json b/zerver/webhooks/facebook/fixtures/user_feed.json similarity index 100% rename from zerver/webhooks/facebook/fixtures/users_feed.json rename to zerver/webhooks/facebook/fixtures/user_feed.json diff --git a/zerver/webhooks/facebook/tests.py b/zerver/webhooks/facebook/tests.py new file mode 100644 index 0000000000..eb30db4ceb --- /dev/null +++ b/zerver/webhooks/facebook/tests.py @@ -0,0 +1,110 @@ +from typing import Optional, Text + +from zerver.lib.test_classes import WebhookTestCase + +class FacebookTests(WebhookTestCase): + STREAM_NAME = 'Facebook' + URL_TEMPLATE = "/api/v1/external/facebook?api_key={api_key}&stream={stream}&token=aaaa" + FIXTURE_DIR_NAME = 'facebook' + + def test_application_plugin_comment(self) -> None: + expected_subject = u'application notification' + expected_message = u'**plugin_comment** received'\ + u'\n**Test User:**'\ + u'\n```quote'\ + u'\nTest Comment'\ + u'\n```' + self.send_and_test_stream_message('application_plugin_comment', + expected_subject, expected_message) + + def test_application_plugin_comment_reply(self) -> None: + expected_subject = u'application notification' + expected_message = u'**plugin_comment_reply** received'\ + u'\n**Test User 1:** (Parent)'\ + u'\n```quote'\ + u'\nTest Parent Comment'\ + u'\n```'\ + u'\n**Test User:**'\ + u'\n```quote'\ + u'\n```quote'\ + u'\nTest Comment'\ + u'\n```'\ + u'\n```' + self.send_and_test_stream_message('application_plugin_comment_reply', + expected_subject, expected_message) + + def test_page_conversations(self) -> None: + expected_subject = u'page notification' + expected_message = u'Updated **conversations**'\ + u'\n[Open conversations...](https://www.facebook.com/'\ + u'4444444/t_mid.14833205540:9182a4e489)' + self.send_and_test_stream_message('page_conversations', + expected_subject, expected_message) + + def test_page_website_test(self) -> None: + expected_subject = u'page notification' + expected_message = u'Changed **website**' + self.send_and_test_stream_message('page_website', + expected_subject, expected_message) + + def test_permissions_ads_management(self) -> None: + expected_subject = u'permissions notification' + expected_message = u'**ads_management permission** changed'\ + u'\n* granted'\ + u'\n * 123123123123123'\ + u'\n * 321321321321321' + self.send_and_test_stream_message('permissions_ads_management', + expected_subject, expected_message) + + def test_permissions_manage_pages(self) -> None: + expected_subject = u'permissions notification' + expected_message = u'**manage_pages permission** changed'\ + u'\n* granted'\ + u'\n * 123123123123123'\ + u'\n * 321321321321321' + self.send_and_test_stream_message('permissions_manage_pages', + expected_subject, expected_message) + + def test_user_email(self) -> None: + expected_subject = u'user notification' + expected_message = u'Changed **email**'\ + u'\nTo: *example_email@facebook.com*' + self.send_and_test_stream_message('user_email', + expected_subject, expected_message) + + def test_user_feed(self) -> None: + expected_subject = u'user notification' + expected_message = u'Changed **feed**' + self.send_and_test_stream_message('user_feed', + expected_subject, expected_message) + + def test_webhook_verify_request(self) -> None: + self.subscribe(self.test_user, self.STREAM_NAME) + get_params = {'stream_name': self.STREAM_NAME, + 'hub.challenge': '9B2SVL4orbt5DxLMqJHI6pOTipTqingt2YFMIO0g06E', + 'api_key': self.test_user.api_key, + 'hub.mode': 'subscribe', + 'hub.verify_token': 'aaaa', + 'token': 'aaaa'} + result = self.client_get(self.url, get_params) + self.assert_in_response('9B2SVL4orbt5DxLMqJHI6pOTipTqingt2YFMIO0g06E', result) + + def test_error_webhook_verify_request_wrong_token(self) -> None: + self.subscribe(self.test_user, self.STREAM_NAME) + get_params = {'stream_name': self.STREAM_NAME, + 'hub.challenge': '9B2SVL4orbt5DxLMqJHI6pOTipTqingt2YFMIO0g06E', + 'api_key': self.test_user.api_key, + 'hub.mode': 'subscribe', + 'hub.verify_token': 'aaaa', + 'token': 'wrong_token'} + result = self.client_get(self.url, get_params) + self.assert_in_response('Error: Token is wrong', result) + + def test_error_webhook_verify_request_unsupported_method(self) -> None: + self.subscribe(self.test_user, self.STREAM_NAME) + get_params = {'stream_name': self.STREAM_NAME, + 'api_key': self.test_user.api_key, + 'hub.mode': 'unsupported_method', + 'token': 'aaaa'} + result = self.client_get(self.url, get_params) + self.assert_in_response('Error: Unsupported method', result) diff --git a/zerver/webhooks/facebook/view.py b/zerver/webhooks/facebook/view.py new file mode 100644 index 0000000000..486bf71eee --- /dev/null +++ b/zerver/webhooks/facebook/view.py @@ -0,0 +1,116 @@ +from typing import Any, Dict, Optional, Text + +from django.http import HttpRequest, HttpResponse, QueryDict +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, create_stream_if_needed +from zerver.lib.request import REQ, has_request_variables +from zerver.lib.response import json_success, json_error +from zerver.models import UserProfile +import json + +class UnknownEventType(Exception): + pass + +def user_event(payload: Dict[Text, Any]) -> Text: + field = payload['entry'][0]['changes'][0]['field'] + message = "Changed **{field}**".format(field=field) + if field == "email": + if payload['entry'][0]['changes'][0]['value'] is not None: + message = message + '\nTo: *{email}*'.format( + email=payload['entry'][0]['changes'][0]['value'] + ) + return message + +def page_event(payload: Dict[Text, Any]) -> Text: + field = payload['entry'][0]['changes'][0]['field'] + message = '' + if field == 'conversations': + message = message + 'Updated **conversations**' + message = message + '\n[Open conversations...](https://www.facebook.com/'\ + '{page_id}/{thread_id})'.format( + page_id=payload['entry'][0]['changes'][0]['value']['page_id'], + thread_id=payload['entry'][0]['changes'][0]['value']['thread_id'] + ) + elif field == 'website': + message = message + 'Changed **website**' + return message + +def permissions_event(payload: Dict[Text, Any]) -> Text: + field = payload['entry'][0]['changes'][0]['field'] + message = '**{field} permission** changed'.format(field=field) + if field == 'ads_management': + message = message + '\n* {verb}'.format( + verb=payload['entry'][0]['changes'][0]['value']['verb'] + ) + for id in payload['entry'][0]['changes'][0]['value']['target_ids']: + message = message + '\n * {id}'.format(id=id) + elif field == 'manage_pages': + message = message + '\n* {verb}'.format( + verb=payload['entry'][0]['changes'][0]['value']['verb'] + ) + for id in payload['entry'][0]['changes'][0]['value']['target_ids']: + message = message + '\n * {id}'.format(id=id) + return message + +def application_event(payload: Dict[Text, Any]) -> Text: + field = payload['entry'][0]['changes'][0]['field'] + message = '**{field}** received'.format(field=field) + if field == 'plugin_comment': + message = message + '\n**{msg_user}:**\n```quote\n{message}\n```'.format( + msg_user=payload['entry'][0]['changes'][0]['value']['from']['name'], + message=payload['entry'][0]['changes'][0]['value']['message'] + ) + if field == 'plugin_comment_reply': + message = message + '\n**{prt_msg_user}:** (Parent)\n'\ + '```quote\n{prt_message}\n```'.format( + prt_msg_user=payload['entry'][0]['changes'][0]['value']['parent']['from']['name'], + prt_message=payload['entry'][0]['changes'][0]['value']['parent']['message'] + ) + message = message + '\n**{cld_msg_user}:**\n```quote\n'\ + '```quote\n{cld_message}\n```\n```'.format( + cld_msg_user=payload['entry'][0]['changes'][0]['value']['from']['name'], + cld_message=payload['entry'][0]['changes'][0]['value']['message'] + ) + return message + +@api_key_only_webhook_view("Facebook") +@has_request_variables +def api_facebook_webhook(request: HttpRequest, user_profile: UserProfile, + stream: Text=REQ(default='Facebook'), token: Text=REQ()) -> HttpResponse: + + if request.method == 'GET': # facebook webhook verify + if request.GET.get("hub.mode") == 'subscribe': + if request.GET.get('hub.verify_token') == token: + return HttpResponse(request.GET.get('hub.challenge')) + else: + return json_error(_('Error: Token is wrong')) + return json_error(_('Error: Unsupported method')) + + payload = json.loads(request.body.decode("UTF-8")) + event = get_event(payload) + if event is not None: + body = get_body_based_on_event(event)(payload) + subject = event + " notification" + check_send_stream_message(user_profile, request.client, + stream, subject, body) + return json_success() + +# This integration doesn't support instant_workflow, instagram +# and certificate_transparency event. +EVENTS_FUNCTION_MAPPER = { + 'user': user_event, + 'page': page_event, + 'permissions': permissions_event, + 'application': application_event +} + +def get_event(payload: Dict[Text, Any]) -> Optional[Text]: + event = payload['object'] + if event in EVENTS_FUNCTION_MAPPER: + return event + raise UnknownEventType(u"OEvent '{}' is unknown and cannot be handled".format(event)) # nocoverage + +def get_body_based_on_event(event: Text) -> Any: + return EVENTS_FUNCTION_MAPPER[event]