2017-08-29 15:21:25 +02:00
|
|
|
from typing import Any, AnyStr, Iterable, Dict, Tuple, Callable, Text, Mapping, Optional
|
2016-07-23 08:13:33 +02:00
|
|
|
|
|
|
|
import requests
|
|
|
|
import json
|
|
|
|
import sys
|
|
|
|
import inspect
|
|
|
|
import logging
|
2017-07-21 06:49:00 +02:00
|
|
|
import re
|
2017-11-05 05:30:31 +01:00
|
|
|
import urllib
|
2016-07-23 08:13:33 +02:00
|
|
|
from functools import reduce
|
2017-05-25 19:16:40 +02:00
|
|
|
from requests import Response
|
2016-07-23 08:13:33 +02:00
|
|
|
|
|
|
|
from django.utils.translation import ugettext as _
|
|
|
|
|
2017-08-25 05:26:31 +02:00
|
|
|
from zerver.models import Realm, UserProfile, get_user_profile_by_id, get_client, \
|
2017-07-24 07:51:18 +02:00
|
|
|
GENERIC_INTERFACE, Service, SLACK_INTERFACE, email_to_domain, get_service_profile
|
2016-07-23 08:13:33 +02:00
|
|
|
from zerver.lib.actions import check_send_message
|
2018-02-15 21:02:47 +01:00
|
|
|
from zerver.lib.notifications import encode_stream
|
2017-10-19 14:52:06 +02:00
|
|
|
from zerver.lib.queue import retry_event
|
2016-07-23 08:13:33 +02:00
|
|
|
from zerver.lib.validator import check_dict, check_string
|
|
|
|
from zerver.decorator import JsonableError
|
|
|
|
|
2017-11-05 11:37:41 +01:00
|
|
|
class OutgoingWebhookServiceInterface:
|
2017-05-25 19:16:40 +02:00
|
|
|
|
2017-11-05 11:15:10 +01:00
|
|
|
def __init__(self, base_url: Text, token: Text, user_profile: UserProfile, service_name: Text) -> None:
|
2017-05-25 19:16:40 +02:00
|
|
|
self.base_url = base_url # type: Text
|
|
|
|
self.token = token # type: Text
|
|
|
|
self.user_profile = user_profile # type: Text
|
|
|
|
self.service_name = service_name # type: Text
|
|
|
|
|
2017-07-24 08:40:59 +02:00
|
|
|
# Given an event that triggers an outgoing webhook operation, returns:
|
|
|
|
# - The REST operation that should be performed
|
|
|
|
# - The body of the request
|
2017-05-25 19:16:40 +02:00
|
|
|
#
|
2017-07-24 08:40:59 +02:00
|
|
|
# The REST operation is a dictionary with the following keys:
|
|
|
|
# - method
|
|
|
|
# - base_url
|
|
|
|
# - relative_url_path
|
|
|
|
# - request_kwargs
|
2017-11-05 11:15:10 +01:00
|
|
|
def process_event(self, event: Dict[Text, Any]) -> Tuple[Dict[str, Any], Any]:
|
2017-05-25 19:16:40 +02:00
|
|
|
raise NotImplementedError()
|
|
|
|
|
2017-07-24 08:40:59 +02:00
|
|
|
# Given a successful outgoing webhook REST operation, returns the message
|
|
|
|
# to sent back to the user (or None if no message should be sent).
|
2017-11-05 11:15:10 +01:00
|
|
|
def process_success(self, response: Response, event: Dict[Text, Any]) -> Optional[str]:
|
2017-05-25 19:16:40 +02:00
|
|
|
raise NotImplementedError()
|
|
|
|
|
2017-07-24 07:51:18 +02:00
|
|
|
class GenericOutgoingWebhookService(OutgoingWebhookServiceInterface):
|
|
|
|
|
2017-11-05 11:15:10 +01:00
|
|
|
def process_event(self, event: Dict[Text, Any]) -> Tuple[Dict[str, Any], Any]:
|
2017-07-24 07:51:18 +02:00
|
|
|
rest_operation = {'method': 'POST',
|
|
|
|
'relative_url_path': '',
|
|
|
|
'base_url': self.base_url,
|
|
|
|
'request_kwargs': {}}
|
|
|
|
request_data = {"data": event['command'],
|
|
|
|
"message": event['message'],
|
|
|
|
"token": self.token}
|
|
|
|
return rest_operation, json.dumps(request_data)
|
|
|
|
|
2017-11-05 11:15:10 +01:00
|
|
|
def process_success(self, response: Response, event: Dict[Text, Any]) -> Optional[str]:
|
2017-07-24 07:51:18 +02:00
|
|
|
response_json = json.loads(response.text)
|
|
|
|
|
|
|
|
if "response_not_required" in response_json and response_json['response_not_required']:
|
|
|
|
return None
|
|
|
|
if "response_string" in response_json:
|
|
|
|
return str(response_json['response_string'])
|
|
|
|
else:
|
2017-07-24 09:02:29 +02:00
|
|
|
return None
|
2017-07-24 07:51:18 +02:00
|
|
|
|
|
|
|
class SlackOutgoingWebhookService(OutgoingWebhookServiceInterface):
|
|
|
|
|
2017-11-05 11:15:10 +01:00
|
|
|
def process_event(self, event: Dict[Text, Any]) -> Tuple[Dict[str, Any], Any]:
|
2017-07-24 07:51:18 +02:00
|
|
|
rest_operation = {'method': 'POST',
|
|
|
|
'relative_url_path': '',
|
|
|
|
'base_url': self.base_url,
|
|
|
|
'request_kwargs': {}}
|
|
|
|
|
|
|
|
if event['message']['type'] == 'private':
|
|
|
|
raise NotImplementedError("Private messaging service not supported.")
|
|
|
|
|
|
|
|
service = get_service_profile(event['user_profile_id'], str(self.service_name))
|
|
|
|
request_data = [("token", self.token),
|
|
|
|
("team_id", event['message']['sender_realm_str']),
|
|
|
|
("team_domain", email_to_domain(event['message']['sender_email'])),
|
|
|
|
("channel_id", event['message']['stream_id']),
|
|
|
|
("channel_name", event['message']['display_recipient']),
|
|
|
|
("timestamp", event['message']['timestamp']),
|
|
|
|
("user_id", event['message']['sender_id']),
|
|
|
|
("user_name", event['message']['sender_full_name']),
|
|
|
|
("text", event['command']),
|
|
|
|
("trigger_word", event['trigger']),
|
|
|
|
("service_id", service.id),
|
|
|
|
]
|
|
|
|
|
|
|
|
return rest_operation, request_data
|
|
|
|
|
2017-11-05 11:15:10 +01:00
|
|
|
def process_success(self, response: Response, event: Dict[Text, Any]) -> Optional[str]:
|
2017-07-24 07:51:18 +02:00
|
|
|
response_json = json.loads(response.text)
|
|
|
|
if "text" in response_json:
|
2017-07-24 09:02:29 +02:00
|
|
|
return response_json["text"]
|
|
|
|
else:
|
|
|
|
return None
|
2017-07-24 07:51:18 +02:00
|
|
|
|
|
|
|
AVAILABLE_OUTGOING_WEBHOOK_INTERFACES = {
|
|
|
|
GENERIC_INTERFACE: GenericOutgoingWebhookService,
|
|
|
|
SLACK_INTERFACE: SlackOutgoingWebhookService,
|
|
|
|
} # type: Dict[Text, Any]
|
|
|
|
|
2017-11-05 11:15:10 +01:00
|
|
|
def get_service_interface_class(interface: Text) -> Any:
|
2017-07-24 07:51:18 +02:00
|
|
|
if interface is None or interface not in AVAILABLE_OUTGOING_WEBHOOK_INTERFACES:
|
|
|
|
return AVAILABLE_OUTGOING_WEBHOOK_INTERFACES[GENERIC_INTERFACE]
|
|
|
|
else:
|
|
|
|
return AVAILABLE_OUTGOING_WEBHOOK_INTERFACES[interface]
|
|
|
|
|
2017-11-05 11:15:10 +01:00
|
|
|
def get_outgoing_webhook_service_handler(service: Service) -> Any:
|
2017-07-24 07:51:18 +02:00
|
|
|
|
|
|
|
service_interface_class = get_service_interface_class(service.interface_name())
|
|
|
|
service_interface = service_interface_class(base_url=service.base_url,
|
|
|
|
token=service.token,
|
|
|
|
user_profile=service.user_profile,
|
|
|
|
service_name=service.name)
|
|
|
|
return service_interface
|
|
|
|
|
2017-11-05 11:15:10 +01:00
|
|
|
def send_response_message(bot_id: str, message: Dict[str, Any], response_message_content: Text) -> None:
|
2016-07-23 08:13:33 +02:00
|
|
|
recipient_type_name = message['type']
|
|
|
|
bot_user = get_user_profile_by_id(bot_id)
|
2017-08-25 05:26:31 +02:00
|
|
|
realm = bot_user.realm
|
2016-07-23 08:13:33 +02:00
|
|
|
|
|
|
|
if recipient_type_name == 'stream':
|
|
|
|
recipients = [message['display_recipient']]
|
|
|
|
check_send_message(bot_user, get_client("OutgoingWebhookResponse"), recipient_type_name, recipients,
|
2017-09-25 13:03:10 +02:00
|
|
|
message['subject'], response_message_content, realm)
|
|
|
|
elif recipient_type_name == 'private':
|
2016-07-23 08:13:33 +02:00
|
|
|
recipients = [recipient['email'] for recipient in message['display_recipient']]
|
2017-09-25 13:03:10 +02:00
|
|
|
check_send_message(bot_user, get_client("OutgoingWebhookResponse"), recipient_type_name, recipients,
|
|
|
|
None, response_message_content, realm)
|
|
|
|
else:
|
|
|
|
raise JsonableError(_("Invalid message type"))
|
2016-07-23 08:13:33 +02:00
|
|
|
|
2017-11-05 11:15:10 +01:00
|
|
|
def succeed_with_message(event: Dict[str, Any], success_message: Text) -> None:
|
2016-07-23 08:13:33 +02:00
|
|
|
success_message = "Success! " + success_message
|
|
|
|
send_response_message(event['user_profile_id'], event['message'], success_message)
|
|
|
|
|
2017-11-05 11:15:10 +01:00
|
|
|
def fail_with_message(event: Dict[str, Any], failure_message: Text) -> None:
|
2016-07-23 08:13:33 +02:00
|
|
|
failure_message = "Failure! " + failure_message
|
|
|
|
send_response_message(event['user_profile_id'], event['message'], failure_message)
|
|
|
|
|
2017-11-05 11:15:10 +01:00
|
|
|
def get_message_url(event: Dict[str, Any], request_data: Dict[str, Any]) -> Text:
|
2017-08-29 15:21:25 +02:00
|
|
|
bot_user = get_user_profile_by_id(event['user_profile_id'])
|
|
|
|
message = event['message']
|
|
|
|
if message['type'] == 'stream':
|
2018-02-15 21:02:47 +01:00
|
|
|
stream_url_frag = encode_stream(message.get('stream_id'), message['display_recipient'])
|
2017-08-29 15:21:25 +02:00
|
|
|
message_url = ("%(server)s/#narrow/stream/%(stream)s/subject/%(subject)s/near/%(id)s"
|
|
|
|
% {'server': bot_user.realm.uri,
|
2018-02-15 21:02:47 +01:00
|
|
|
'stream': stream_url_frag,
|
2017-08-29 15:21:25 +02:00
|
|
|
'subject': message['subject'],
|
|
|
|
'id': str(message['id'])})
|
|
|
|
else:
|
|
|
|
recipient_emails = ','.join([recipient['email'] for recipient in message['display_recipient']])
|
|
|
|
recipient_email_encoded = urllib.parse.quote(recipient_emails).replace('.', '%2E').replace('%', '.')
|
|
|
|
message_url = ("%(server)s/#narrow/pm-with/%(recipient_emails)s/near/%(id)s"
|
|
|
|
% {'server': bot_user.realm.uri,
|
|
|
|
'recipient_emails': recipient_email_encoded,
|
|
|
|
'id': str(message['id'])})
|
|
|
|
return message_url
|
|
|
|
|
2017-11-05 11:15:10 +01:00
|
|
|
def notify_bot_owner(event: Dict[str, Any],
|
|
|
|
request_data: Dict[str, Any],
|
|
|
|
status_code: Optional[int]=None,
|
|
|
|
response_content: Optional[AnyStr]=None,
|
|
|
|
exception: Optional[Exception]=None) -> None:
|
2017-08-29 15:21:25 +02:00
|
|
|
message_url = get_message_url(event, request_data)
|
|
|
|
bot_id = event['user_profile_id']
|
|
|
|
bot_owner = get_user_profile_by_id(bot_id).bot_owner
|
|
|
|
message_info = {'display_recipient': [{'email': bot_owner.email}],
|
|
|
|
'type': 'private'}
|
|
|
|
notification_message = "[A message](%s) triggered an outgoing webhook." % (message_url,)
|
|
|
|
if status_code:
|
|
|
|
notification_message += "\nThe webhook got a response with status code *%s*." % (status_code,)
|
|
|
|
if response_content:
|
|
|
|
notification_message += "\nThe response contains the following payload:\n" \
|
|
|
|
"```\n%s\n```" % (response_content,)
|
|
|
|
if exception:
|
|
|
|
notification_message += "\nWhen trying to send a request to the webhook service, an exception " \
|
2017-11-10 03:34:13 +01:00
|
|
|
"of type %s occurred:\n```\n%s\n```" % (
|
|
|
|
type(exception).__name__, str(exception))
|
2017-08-29 15:21:25 +02:00
|
|
|
send_response_message(bot_id, message_info, notification_message)
|
|
|
|
|
2017-11-05 11:15:10 +01:00
|
|
|
def request_retry(event: Dict[str, Any],
|
|
|
|
request_data: Dict[str, Any],
|
|
|
|
failure_message: Text,
|
|
|
|
exception: Optional[Exception]=None) -> None:
|
|
|
|
def failure_processor(event: Dict[str, Any]) -> None:
|
2017-08-18 07:56:53 +02:00
|
|
|
"""
|
|
|
|
The name of the argument is 'event' on purpose. This argument will hide
|
|
|
|
the 'event' argument of the request_retry function. Keeping the same name
|
|
|
|
results in a smaller diff.
|
|
|
|
"""
|
2016-07-23 08:13:33 +02:00
|
|
|
bot_user = get_user_profile_by_id(event['user_profile_id'])
|
2017-08-18 07:56:53 +02:00
|
|
|
fail_with_message(event, "Maximum retries exceeded! " + failure_message)
|
2017-08-16 13:30:47 +02:00
|
|
|
notify_bot_owner(event, request_data, exception=exception)
|
2017-11-10 03:34:13 +01:00
|
|
|
logging.warning("Maximum retries exceeded for trigger:%s event:%s" % (
|
|
|
|
bot_user.email, event['command']))
|
2017-08-18 07:56:53 +02:00
|
|
|
|
|
|
|
retry_event('outgoing_webhooks', event, failure_processor)
|
2016-07-23 08:13:33 +02:00
|
|
|
|
2017-11-05 11:15:10 +01:00
|
|
|
def do_rest_call(rest_operation: Dict[str, Any],
|
|
|
|
request_data: Optional[Dict[str, Any]],
|
|
|
|
event: Dict[str, Any],
|
|
|
|
service_handler: Any,
|
|
|
|
timeout: Any=None) -> None:
|
2016-07-23 08:13:33 +02:00
|
|
|
rest_operation_validator = check_dict([
|
|
|
|
('method', check_string),
|
|
|
|
('relative_url_path', check_string),
|
|
|
|
('request_kwargs', check_dict([])),
|
|
|
|
('base_url', check_string),
|
|
|
|
])
|
|
|
|
|
|
|
|
error = rest_operation_validator('rest_operation', rest_operation)
|
|
|
|
if error:
|
2017-06-06 20:59:36 +02:00
|
|
|
raise JsonableError(error)
|
2016-07-23 08:13:33 +02:00
|
|
|
|
|
|
|
http_method = rest_operation['method']
|
|
|
|
final_url = urllib.parse.urljoin(rest_operation['base_url'], rest_operation['relative_url_path'])
|
|
|
|
request_kwargs = rest_operation['request_kwargs']
|
|
|
|
request_kwargs['timeout'] = timeout
|
|
|
|
|
|
|
|
try:
|
2017-07-21 06:58:44 +02:00
|
|
|
response = requests.request(http_method, final_url, data=request_data, **request_kwargs)
|
2016-07-23 08:13:33 +02:00
|
|
|
if str(response.status_code).startswith('2'):
|
2017-07-21 06:49:00 +02:00
|
|
|
response_message = service_handler.process_success(response, event)
|
2017-07-24 09:08:20 +02:00
|
|
|
if response_message is not None:
|
|
|
|
succeed_with_message(event, response_message)
|
2016-07-23 08:13:33 +02:00
|
|
|
else:
|
2017-08-28 18:51:13 +02:00
|
|
|
logging.warning("Message %(message_url)s triggered an outgoing webhook, returning status "
|
|
|
|
"code %(status_code)s.\n Content of response (in quotes): \""
|
|
|
|
"%(response)s\""
|
2017-08-29 15:21:25 +02:00
|
|
|
% {'message_url': get_message_url(event, request_data),
|
2017-08-28 18:51:13 +02:00
|
|
|
'status_code': response.status_code,
|
|
|
|
'response': response.content})
|
2017-09-25 16:49:28 +02:00
|
|
|
failure_message = "Third party responded with %d" % (response.status_code)
|
|
|
|
fail_with_message(event, failure_message)
|
|
|
|
notify_bot_owner(event, request_data, response.status_code, response.content)
|
2016-07-23 08:13:33 +02:00
|
|
|
|
2017-08-16 13:30:47 +02:00
|
|
|
except requests.exceptions.Timeout as e:
|
2017-11-10 03:34:13 +01:00
|
|
|
logging.info("Trigger event %s on %s timed out. Retrying" % (
|
|
|
|
event["command"], event['service_name']))
|
2017-08-16 13:30:47 +02:00
|
|
|
request_retry(event, request_data, 'Unable to connect with the third party.', exception=e)
|
|
|
|
|
|
|
|
except requests.exceptions.ConnectionError as e:
|
2017-11-10 03:34:13 +01:00
|
|
|
response_message = ("The message `%s` resulted in a connection error when "
|
|
|
|
"sending a request to an outgoing "
|
|
|
|
"webhook! See the Zulip server logs for more information." % (event["command"],))
|
2017-08-16 13:30:47 +02:00
|
|
|
logging.info("Trigger event %s on %s resulted in a connection error. Retrying"
|
|
|
|
% (event["command"], event['service_name']))
|
|
|
|
request_retry(event, request_data, response_message, exception=e)
|
2016-07-23 08:13:33 +02:00
|
|
|
|
|
|
|
except requests.exceptions.RequestException as e:
|
2017-11-10 03:34:13 +01:00
|
|
|
response_message = ("An exception of type *%s* occurred for message `%s`! "
|
|
|
|
"See the Zulip server logs for more information." % (
|
|
|
|
type(e).__name__, event["command"],))
|
2016-07-23 08:13:33 +02:00
|
|
|
logging.exception("Outhook trigger failed:\n %s" % (e,))
|
|
|
|
fail_with_message(event, response_message)
|
2017-08-29 15:21:25 +02:00
|
|
|
notify_bot_owner(event, request_data, exception=e)
|