From 23957a33379eaf962c5775a85b82cfd226312fad Mon Sep 17 00:00:00 2001 From: Chris Caron Date: Sun, 10 Jan 2021 15:55:59 -0500 Subject: [PATCH] Added support for Slack email address targets (#345) --- apprise/plugins/NotifySlack.py | 330 +++++++++++++++++++++++++++------ test/test_api.py | 117 +++++++----- test/test_cli.py | 7 +- test/test_rest_plugins.py | 105 +++++++---- test/test_slack_plugin.py | 251 ++++++++++++++++++++++++- 5 files changed, 659 insertions(+), 151 deletions(-) diff --git a/apprise/plugins/NotifySlack.py b/apprise/plugins/NotifySlack.py index 3e024a64..ede0d315 100644 --- a/apprise/plugins/NotifySlack.py +++ b/apprise/plugins/NotifySlack.py @@ -43,7 +43,7 @@ # to add a 'Bot User'. Give it a name and choose 'Add Bot User'. # 4. Now you can choose 'Install App' to which you can choose 'Install App # to Workspace'. -# 5. You will need to authorize the app which you get promopted to do. +# 5. You will need to authorize the app which you get prompted to do. # 6. Finally you'll get some important information providing you your # 'OAuth Access Token' and 'Bot User OAuth Access Token' such as: # slack://{Oauth Access Token} @@ -53,6 +53,21 @@ # ... or: # slack://xoxb-1234-1234-4ddbc191d40ee098cbaae6f3523ada2d # +# You must at least give your bot the following access for it to +# be useful: +# - chat:write - MUST be set otherwise you can not post into +# a channel +# - users:read.email - Required if you want to be able to lookup +# users by their email address. +# +# The easiest way to bring a bot into a channel (so that it can send +# a message to it is to invite it. At this time Apprise does not support +# an auto-join functionality. To do this: +# - In the 'Details' section of your channel +# - Click on the 'More' [...] (elipse icon) +# - Click 'Add apps' +# - You will be able to select the Bot App you previously created +# - Your bot will join your channel. import re import requests @@ -64,6 +79,7 @@ from .NotifyBase import NotifyBase from ..common import NotifyImageSize from ..common import NotifyType from ..common import NotifyFormat +from ..utils import is_email from ..utils import parse_bool from ..utils import parse_list from ..utils import validate_regex @@ -202,6 +218,11 @@ class NotifySlack(NotifyBase): 'prefix': '+', 'map_to': 'targets', }, + 'target_email': { + 'name': _('Target Email'), + 'type': 'string', + 'map_to': 'targets', + }, 'target_user': { 'name': _('Target User'), 'type': 'string', @@ -237,6 +258,10 @@ class NotifySlack(NotifyBase): 'to': { 'alias_of': 'targets', }, + 'token': { + 'name': _('Token'), + 'alias_of': ('access_token', 'token_a', 'token_b', 'token_c'), + }, }) def __init__(self, access_token=None, token_a=None, token_b=None, @@ -287,6 +312,11 @@ class NotifySlack(NotifyBase): self.logger.warning( 'No user was specified; using "%s".' % self.app_id) + # Look the users up by their email address and map them back to their + # id here for future queries (if needed). This allows people to + # specify a full email as a recipient via slack + self._lookup_users = {} + # Build list of channels self.channels = parse_list(targets) if len(self.channels) == 0: @@ -382,30 +412,42 @@ class NotifySlack(NotifyBase): channel = channels.pop(0) if channel is not None: - _channel = validate_regex( - channel, r'[+#@]?(?P[A-Z0-9_]{1,32})') - - if not _channel: + channel = validate_regex(channel, r'[+#@]?[A-Z0-9_]{1,32}') + if not channel: # Channel over-ride was specified self.logger.warning( "The specified target {} is invalid;" - "skipping.".format(_channel)) + "skipping.".format(channel)) # Mark our failure has_error = True continue - if len(_channel) > 1 and _channel[0] == '+': + if channel[0] == '+': # Treat as encoded id if prefixed with a + - payload['channel'] = _channel[1:] + payload['channel'] = channel[1:] - elif len(_channel) > 1 and _channel[0] == '@': + elif channel[0] == '@': # Treat @ value 'as is' - payload['channel'] = _channel + payload['channel'] = channel else: - # Prefix with channel hash tag - payload['channel'] = '#{}'.format(_channel) + # We'll perform a user lookup if we detect an email + email = is_email(channel) + if email: + payload['channel'] = \ + self.lookup_userid(email['full_email']) + + if not payload['channel']: + # Move along; any notifications/logging would have + # come from lookup_userid() + has_error = True + continue + else: + # Prefix with channel hash tag (if not already) + payload['channel'] = \ + channel if channel[0] == '#' \ + else '#{}'.format(channel) # Store the valid and massaged payload that is recognizable by # slack. This list is used for sending attachments later. @@ -465,6 +507,162 @@ class NotifySlack(NotifyBase): return not has_error + def lookup_userid(self, email): + """ + Takes an email address and attempts to resolve/acquire it's user + id for notification purposes. + """ + if email in self._lookup_users: + # We're done as entry has already been retrieved + return self._lookup_users[email] + + if self.mode is not SlackMode.BOT: + # You can not look up + self.logger.warning( + 'Emails can not be resolved to Slack User IDs unless you ' + 'have a bot configured.') + return None + + lookup_url = self.api_url.format('users.lookupByEmail') + headers = { + 'User-Agent': self.app_id, + 'Content-Type': 'application/x-www-form-urlencoded', + 'Authorization': 'Bearer {}'.format(self.access_token), + } + + # we pass in our email address as the argument + params = { + 'email': email, + } + + self.logger.debug('Slack User Lookup POST URL: %s (cert_verify=%r)' % ( + lookup_url, self.verify_certificate, + )) + self.logger.debug('Slack User Lookup Parameters: %s' % str(params)) + + # Initialize our HTTP JSON response + response = {'ok': False} + + # Initialize our detected user id (also the response to this function) + user_id = None + + # Always call throttle before any remote server i/o is made + self.throttle() + try: + r = requests.get( + lookup_url, + headers=headers, + params=params, + verify=self.verify_certificate, + timeout=self.request_timeout, + ) + + # Attachment posts return a JSON string + try: + response = loads(r.content) + + except (AttributeError, TypeError, ValueError): + # ValueError = r.content is Unparsable + # TypeError = r.content is None + # AttributeError = r is None + pass + + # We can get a 200 response, but still fail. A failure message + # might look like this (missing bot permissions): + # { + # 'ok': False, + # 'error': 'missing_scope', + # 'needed': 'users:read.email', + # 'provided': 'calls:write,chat:write' + # } + + if r.status_code != requests.codes.ok \ + or not (response and response.get('ok', False)): + + # We had a problem + status_str = \ + NotifySlack.http_response_code_lookup( + r.status_code, SLACK_HTTP_ERROR_MAP) + + self.logger.warning( + 'Failed to send Slack User Lookup:' + '{}{}error={}.'.format( + status_str, + ', ' if status_str else '', + r.status_code)) + + self.logger.debug('Response Details:\r\n{}'.format(r.content)) + # Return; we're done + return False + + # If we reach here, then we were successful in looking up + # the user. A response generally looks like this: + # { + # 'ok': True, + # 'user': { + # 'id': 'J1ZQB9T9Y', + # 'team_id': 'K1WR6TML2', + # 'name': 'l2g', + # 'deleted': False, + # 'color': '9f69e7', + # 'real_name': 'Chris C', + # 'tz': 'America/New_York', + # 'tz_label': 'Eastern Standard Time', + # 'tz_offset': -18000, + # 'profile': { + # 'title': '', + # 'phone': '', + # 'skype': '', + # 'real_name': 'Chris C', + # 'real_name_normalized': + # 'Chris C', + # 'display_name': 'l2g', + # 'display_name_normalized': 'l2g', + # 'fields': None, + # 'status_text': '', + # 'status_emoji': '', + # 'status_expiration': 0, + # 'avatar_hash': 'g785e9c0ddf6', + # 'email': 'lead2gold@gmail.com', + # 'first_name': 'Chris', + # 'last_name': 'C', + # 'image_24': 'https://secure.gravatar.com/...', + # 'image_32': 'https://secure.gravatar.com/...', + # 'image_48': 'https://secure.gravatar.com/...', + # 'image_72': 'https://secure.gravatar.com/...', + # 'image_192': 'https://secure.gravatar.com/...', + # 'image_512': 'https://secure.gravatar.com/...', + # 'status_text_canonical': '', + # 'team': 'K1WR6TML2' + # }, + # 'is_admin': True, + # 'is_owner': True, + # 'is_primary_owner': True, + # 'is_restricted': False, + # 'is_ultra_restricted': False, + # 'is_bot': False, + # 'is_app_user': False, + # 'updated': 1603904274 + # } + # } + # We're only interested in the id + user_id = response['user']['id'] + + # Cache it for future + self._lookup_users[email] = user_id + self.logger.info( + 'Email %s resolves to the Slack User ID: %s.', email, user_id) + + except requests.RequestException as e: + self.logger.warning( + 'A Connection error occurred looking up Slack User.', + ) + self.logger.debug('Socket Exception: %s' % str(e)) + # Return; we're done + return None + + return user_id + def _send(self, url, payload, attach=None, **kwargs): """ Wrapper to the requests (post) object @@ -477,6 +675,7 @@ class NotifySlack(NotifyBase): headers = { 'User-Agent': self.app_id, + 'Accept': 'application/json', } if not attach: @@ -486,7 +685,7 @@ class NotifySlack(NotifyBase): headers['Authorization'] = 'Bearer {}'.format(self.access_token) # Our response object - response = None + response = {'ok': False} # Always call throttle before any remote server i/o is made self.throttle() @@ -508,7 +707,28 @@ class NotifySlack(NotifyBase): timeout=self.request_timeout, ) - if r.status_code != requests.codes.ok: + # Posts return a JSON string + try: + response = loads(r.content) + + except (AttributeError, TypeError, ValueError): + # ValueError = r.content is Unparsable + # TypeError = r.content is None + # AttributeError = r is None + pass + + # Another response type is: + # { + # 'ok': False, + # 'error': 'not_in_channel', + # } + # + # The text 'ok' is returned if this is a Webhook request + # So the below captures that as well. + status_okay = (response and response.get('ok', False)) \ + if self.mode is SlackMode.BOT else r.text == 'ok' + + if r.status_code != requests.codes.ok or not status_okay: # We had a problem status_str = \ NotifySlack.http_response_code_lookup( @@ -526,30 +746,6 @@ class NotifySlack(NotifyBase): 'Response Details:\r\n{}'.format(r.content)) return False - elif attach: - # Attachment posts return a JSON string - try: - response = loads(r.content) - - except (AttributeError, TypeError, ValueError): - # ValueError = r.content is Unparsable - # TypeError = r.content is None - # AttributeError = r is None - pass - - if not (response and response.get('ok', True)): - # Bare minimum requirements not met - self.logger.warning( - 'Failed to send {}to Slack: error={}.'.format( - attach.name if attach else '', - r.status_code)) - - self.logger.debug( - 'Response Details:\r\n{}'.format(r.content)) - return False - else: - response = r.content - # Message Post Response looks like this: # { # "attachments": [ @@ -658,14 +854,14 @@ class NotifySlack(NotifyBase): # Extend our parameters params.update(self.url_parameters(privacy=privacy, *args, **kwargs)) - if self.mode == SlackMode.WEBHOOK: - # Determine if there is a botname present - botname = '' - if self.user: - botname = '{botname}@'.format( - botname=NotifySlack.quote(self.user, safe=''), - ) + # Determine if there is a botname present + botname = '' + if self.user: + botname = '{botname}@'.format( + botname=NotifySlack.quote(self.user, safe=''), + ) + if self.mode == SlackMode.WEBHOOK: return '{schema}://{botname}{token_a}/{token_b}/{token_c}/'\ '{targets}/?{params}'.format( schema=self.secure_protocol, @@ -679,9 +875,10 @@ class NotifySlack(NotifyBase): params=NotifySlack.urlencode(params), ) # else -> self.mode == SlackMode.BOT: - return '{schema}://{access_token}/{targets}/'\ + return '{schema}://{botname}{access_token}/{targets}/'\ '?{params}'.format( schema=self.secure_protocol, + botname=botname, access_token=self.pprint(self.access_token, privacy, safe=''), targets='/'.join( [NotifySlack.quote(x, safe='') for x in self.channels]), @@ -714,25 +911,36 @@ class NotifySlack(NotifyBase): else: # We're dealing with a webhook results['token_a'] = token - - # Now fetch the remaining tokens - try: - results['token_b'] = entries.pop(0) - - except IndexError: - # We're done - results['token_b'] = None - - try: - results['token_c'] = entries.pop(0) - - except IndexError: - # We're done - results['token_c'] = None + results['token_b'] = entries.pop(0) if entries else None + results['token_c'] = entries.pop(0) if entries else None # assign remaining entries to the channels we wish to notify results['targets'] = entries + # Support the token flag where you can set it to the bot token + # or the webhook token (with slash delimiters) + if 'token' in results['qsd'] and len(results['qsd']['token']): + # Break our entries up into a list; we can ue the Channel + # list delimiter above since it doesn't contain any characters + # we don't otherwise accept anyway in our token + entries = [x for x in filter( + bool, CHANNEL_LIST_DELIM.split( + NotifySlack.unquote(results['qsd']['token'])))] + + # check to see if we're dealing with a bot/user token + if entries and entries[0].startswith('xo'): + # We're dealing with a bot + results['access_token'] = entries[0] + results['token_a'] = None + results['token_b'] = None + results['token_c'] = None + + else: # Webhook + results['access_token'] = None + results['token_a'] = entries.pop(0) if entries else None + results['token_b'] = entries.pop(0) if entries else None + results['token_c'] = entries.pop(0) if entries else None + # Support the 'to' variable so that we can support rooms this way too # The 'to' makes it easier to use yaml configuration if 'to' in results['qsd'] and len(results['qsd']['to']): diff --git a/test/test_api.py b/test/test_api.py index c0bddae7..f6a9b3bd 100644 --- a/test/test_api.py +++ b/test/test_api.py @@ -1270,59 +1270,90 @@ def test_apprise_details_plugin_verification(): assert len(arg['delim']) > 0 else: # alias_of is in the object - # must be a string - assert isinstance(arg['alias_of'], six.string_types) - # Track our alias_of object - map_to_aliases.add(arg['alias_of']) - # Ensure we're not already in the tokens section # The alias_of object has no value here assert section != 'tokens' - # We can't be an alias_of ourselves - if key == arg['alias_of']: - # This is acceptable as long as we exist in the tokens - # table because that is truely what we map back to - assert key in entry['details']['tokens'] + # must be a string + assert isinstance( + arg['alias_of'], (six.string_types, list, tuple, set)) - else: - # Throw the problem into an assert tag for debugging - # purposes... the mapping is not acceptable - assert key != arg['alias_of'] + aliases = [arg['alias_of']] \ + if isinstance(arg['alias_of'], six.string_types) \ + else arg['alias_of'] - # alias_of always references back to tokens - assert \ - arg['alias_of'] in entry['details']['tokens'] or \ - arg['alias_of'] in entry['details']['args'] + for alias_of in aliases: + # Track our alias_of object + map_to_aliases.add(alias_of) - # Find a list directive in our tokens - t_match = entry['details']['tokens']\ - .get(arg['alias_of'], {})\ - .get('type', '').startswith('list') + # We can't be an alias_of ourselves + if key == alias_of: + # This is acceptable as long as we exist in the + # tokens table because that is truely what we map + # back to + assert key in entry['details']['tokens'] - a_match = entry['details']['args']\ - .get(arg['alias_of'], {})\ - .get('type', '').startswith('list') + else: + # Throw the problem into an assert tag for + # debugging purposes... the mapping is not + # acceptable + assert key != alias_of - if not (t_match or a_match): - # Ensure the only token we have is the alias_of - assert len(entry['details'][section][key]) == 1 + # alias_of always references back to tokens + assert \ + alias_of in entry['details']['tokens'] or \ + alias_of in entry['details']['args'] - else: - # We're a list, we allow up to 2 variables - # Obviously we have the alias_of entry; that's why - # were at this part of the code. But we can - # additionally provide a 'delim' over-ride. - assert len(entry['details'][section][key]) <= 2 - if len(entry['details'][section][key]) == 2: - # Verify that it is in fact the 'delim' tag - assert 'delim' in entry['details'][section][key] - # If we do have a delim value set, it must be of - # a list/set/tuple type - assert isinstance( - entry['details'][section][key]['delim'], - (tuple, set, list), - ) + # Find a list directive in our tokens + t_match = entry['details']['tokens']\ + .get(alias_of, {})\ + .get('type', '').startswith('list') + + a_match = entry['details']['args']\ + .get(alias_of, {})\ + .get('type', '').startswith('list') + + if not (t_match or a_match): + # Ensure the only token we have is the alias_of + # hence record should look like as example): + # { + # 'token': { + # 'alias_of': 'apitoken', + # }, + # } + # + # Or if it can represent more then one entry; in + # this case, one must define a name (to define + # grouping). + # { + # 'token': { + # 'name': 'Tokens', + # 'alias_of': ('apitoken', 'webtoken'), + # }, + # } + if isinstance(arg['alias_of'], six.string_types): + assert len(entry['details'][section][key]) == 1 + else: # is tuple,list, or set + assert len(entry['details'][section][key]) == 2 + # Must have a name defined to define grouping + assert 'name' in entry['details'][section][key] + + else: + # We're a list, we allow up to 2 variables + # Obviously we have the alias_of entry; that's why + # were at this part of the code. But we can + # additionally provide a 'delim' over-ride. + assert len(entry['details'][section][key]) <= 2 + if len(entry['details'][section][key]) == 2: + # Verify that it is in fact the 'delim' tag + assert 'delim' in \ + entry['details'][section][key] + # If we do have a delim value set, it must be + # of a list/set/tuple type + assert isinstance( + entry['details'][section][key]['delim'], + (tuple, set, list), + ) if six.PY2: # inspect our object diff --git a/test/test_cli.py b/test/test_cli.py index b9bdbb3a..9aeeeaf6 100644 --- a/test/test_cli.py +++ b/test/test_cli.py @@ -433,9 +433,10 @@ def test_apprise_cli_nux_env(tmpdir): with mock.patch('apprise.cli.DEFAULT_SEARCH_PATHS', []): with environ(APPRISE_URLS=" "): - # An empty string is not valid and therefore not loaded so the below - # fails. We override the DEFAULT_SEARCH_PATHS because we don't - # want to detect ones loaded on the machine running the unit tests + # An empty string is not valid and therefore not loaded so the + # below fails. We override the DEFAULT_SEARCH_PATHS because we + # don't want to detect ones loaded on the machine running the unit + # tests result = runner.invoke(cli.main, [ '-b', 'test environment', ]) diff --git a/test/test_rest_plugins.py b/test/test_rest_plugins.py index 197c0507..21618db0 100644 --- a/test/test_rest_plugins.py +++ b/test/test_rest_plugins.py @@ -4070,19 +4070,13 @@ TEST_URLS = ( 'instance': plugins.NotifySlack, # don't include an image by default 'include_image': False, - 'requests_response_text': { - 'ok': True, - 'message': '', - }, + 'requests_response_text': 'ok' }), ('slack://T1JJ3T3L2/A1BRTD4JD/TIiajkdnlazkcOXrIdevi7FQ/+id/@id/', { # + encoded id, # @ userid 'instance': plugins.NotifySlack, - 'requests_response_text': { - 'ok': True, - 'message': '', - }, + 'requests_response_text': 'ok', }), ('slack://username@T1JJ3T3L2/A1BRTD4JD/TIiajkdnlazkcOXrIdevi7FQ/' '?to=#nuxref', { @@ -4090,17 +4084,35 @@ TEST_URLS = ( # Our expected url(privacy=True) startswith() response: 'privacy_url': 'slack://username@T...2/A...D/T...Q/', - 'requests_response_text': { - 'ok': True, - 'message': '', - }, + 'requests_response_text': 'ok', }), ('slack://username@T1JJ3T3L2/A1BRTD4JD/TIiajkdnlazkcOXrIdevi7FQ/#nuxref', { 'instance': plugins.NotifySlack, - 'requests_response_text': { - 'ok': True, - 'message': '', - }, + 'requests_response_text': 'ok', + }), + # You can't send to email using webhook + ('slack://T1JJ3T3L2/A1BRTD4JD/TIiajkdnl/user@gmail.com', { + 'instance': plugins.NotifySlack, + 'requests_response_text': 'ok', + # we'll have a notify response failure in this case + 'notify_response': False, + }), + # Specify Token on argument string (with username) + ('slack://bot@_/#nuxref?token=T1JJ3T3L2/A1BRTD4JD/TIiajkdnadfdajkjkfl/', { + 'instance': plugins.NotifySlack, + 'requests_response_text': 'ok', + }), + # Specify Token and channels on argument string (no username) + ('slack://?token=T1JJ3T3L2/A1BRTD4JD/TIiajkdnlazkcOXrIdevi7FQ/&to=#chan', { + 'instance': plugins.NotifySlack, + 'requests_response_text': 'ok', + }), + # Test webhook that doesn't have a proper response + ('slack://username@T1JJ3T3L2/A1BRTD4JD/TIiajkdnlazkcOXrIdevi7FQ/#nuxref', { + 'instance': plugins.NotifySlack, + 'requests_response_text': 'fail', + # we'll have a notify response failure in this case + 'notify_response': False, }), # Test using a bot-token (also test footer set to no flag) ('slack://username@xoxb-1234-1234-abc124/#nuxref?footer=no', { @@ -4114,7 +4126,34 @@ TEST_URLS = ( }, }, }), - + # Test using a bot-token as argument + ('slack://?token=xoxb-1234-1234-abc124&to=#nuxref&footer=no&user=test', { + 'instance': plugins.NotifySlack, + 'requests_response_text': { + 'ok': True, + 'message': '', + # support attachments + 'file': { + 'url_private': 'http://localhost/', + }, + }, + # Our expected url(privacy=True) startswith() response: + 'privacy_url': 'slack://test@x...4/nuxref/', + }), + # We contain 1 or more invalid channels, so we'll fail on our notify call + ('slack://?token=xoxb-1234-1234-abc124&to=#nuxref,#$,#-&footer=no', { + 'instance': plugins.NotifySlack, + 'requests_response_text': { + 'ok': True, + 'message': '', + # support attachments + 'file': { + 'url_private': 'http://localhost/', + }, + }, + # We fail because of the empty channel #$ and #- + 'notify_response': False, + }), ('slack://username@xoxb-1234-1234-abc124/#nuxref', { 'instance': plugins.NotifySlack, 'requests_response_text': { @@ -4129,28 +4168,19 @@ TEST_URLS = ( ('slack://username@T1JJ3T3L2/A1BRTD4JD/TIiajkdnlazkcOXrIdevi7FQ', { # Missing a channel, falls back to webhook channel bindings 'instance': plugins.NotifySlack, - 'requests_response_text': { - 'ok': True, - 'message': '', - }, + 'requests_response_text': 'ok', }), # Native URL Support, take the slack URL and still build from it ('https://hooks.slack.com/services/{}/{}/{}'.format( 'A' * 9, 'B' * 9, 'c' * 24), { 'instance': plugins.NotifySlack, - 'requests_response_text': { - 'ok': True, - 'message': '', - }, + 'requests_response_text': 'ok', }), # Native URL Support with arguments ('https://hooks.slack.com/services/{}/{}/{}?format=text'.format( 'A' * 9, 'B' * 9, 'c' * 24), { 'instance': plugins.NotifySlack, - 'requests_response_text': { - 'ok': True, - 'message': '', - }, + 'requests_response_text': 'ok', }), ('slack://username@-INVALID-/A1BRTD4JD/TIiajkdnlazkcOXrIdevi7FQ/#cool', { # invalid 1st Token @@ -4169,30 +4199,21 @@ TEST_URLS = ( # force a failure 'response': False, 'requests_response_code': requests.codes.internal_server_error, - 'requests_response_text': { - 'ok': False, - 'message': '', - }, + 'requests_response_text': 'ok', }), ('slack://respect@T1JJ3T3L2/A1BRTD4JD/TIiajkdnlazkcOXrIdevi7FQ/#a', { 'instance': plugins.NotifySlack, # throw a bizzare code forcing us to fail to look it up 'response': False, 'requests_response_code': 999, - 'requests_response_text': { - 'ok': False, - 'message': '', - }, + 'requests_response_text': 'ok', }), ('slack://notify@T1JJ3T3L2/A1BRTD4JD/TIiajkdnlazkcOXrIdevi7FQ/#b', { 'instance': plugins.NotifySlack, # Throws a series of connection and transfer exceptions when this flag # is set and tests that we gracfully handle them 'test_requests_exceptions': True, - 'requests_response_text': { - 'ok': False, - 'message': '', - }, + 'requests_response_text': 'ok', }), ################################## @@ -5194,6 +5215,8 @@ def test_rest_plugins(mock_post, mock_get): # Handle our default text response mock_get.return_value.content = requests_response_text mock_post.return_value.content = requests_response_text + mock_get.return_value.text = requests_response_text + mock_post.return_value.text = requests_response_text # Ensure there is no side effect set mock_post.side_effect = None diff --git a/test/test_slack_plugin.py b/test/test_slack_plugin.py index 8ba4b4c7..69b27620 100644 --- a/test/test_slack_plugin.py +++ b/test/test_slack_plugin.py @@ -85,12 +85,39 @@ def test_slack_oauth_access_token(mock_post): assert obj.send(body="test") is True # Test Valid Attachment + mock_post.reset_mock() + path = os.path.join(TEST_VAR_DIR, 'apprise-test.gif') attach = AppriseAttachment(path) assert obj.notify( body='body', title='title', notify_type=NotifyType.INFO, attach=attach) is True + assert mock_post.call_count == 2 + assert mock_post.call_args_list[0][0][0] == \ + 'https://slack.com/api/chat.postMessage' + assert mock_post.call_args_list[1][0][0] == \ + 'https://slack.com/api/files.upload' + + # Test a valid attachment that throws an Connection Error + mock_post.return_value = None + mock_post.side_effect = (request, requests.ConnectionError( + 0, 'requests.ConnectionError() not handled')) + assert obj.notify( + body='body', title='title', notify_type=NotifyType.INFO, + attach=attach) is False + + # Test a valid attachment that throws an OSError + mock_post.return_value = None + mock_post.side_effect = (request, OSError(0, 'OSError')) + assert obj.notify( + body='body', title='title', notify_type=NotifyType.INFO, + attach=attach) is False + + # Reset our mock object back to how it was + mock_post.return_value = request + mock_post.side_effect = None + # Test invalid attachment path = os.path.join(TEST_VAR_DIR, '/invalid/path/to/an/invalid/file.jpg') assert obj.notify( @@ -135,6 +162,8 @@ def test_slack_oauth_access_token(mock_post): # We'll fail now because of an internal exception assert obj.send(body="test") is False + # Test Email Lookup + @mock.patch('requests.post') def test_slack_webhook(mock_post): @@ -148,9 +177,8 @@ def test_slack_webhook(mock_post): # Prepare Mock mock_post.return_value = requests.Request() mock_post.return_value.status_code = requests.codes.ok - mock_post.return_value.content = dumps({ - 'ok': True, - }) + mock_post.return_value.content = 'ok' + mock_post.return_value.text = 'ok' # Initialize some generic (but valid) tokens token_a = 'A' * 9 @@ -182,3 +210,220 @@ def test_slack_webhook(mock_post): # This call includes an image with it's payload: assert obj.notify( body='body', title='title', notify_type=NotifyType.INFO) is True + + +@mock.patch('requests.post') +@mock.patch('requests.get') +def test_slack_send_by_email(mock_get, mock_post): + """ + API: NotifySlack() Send by Email Tests + + """ + # Disable Throttling to speed testing + plugins.NotifyBase.request_rate_per_sec = 0 + + # Generate a (valid) bot token + token = 'xoxb-1234-1234-abc124' + + request = mock.Mock() + request.content = dumps({ + 'ok': True, + 'message': '', + 'user': { + 'id': 'ABCD1234' + } + }) + request.status_code = requests.codes.ok + + # Prepare Mock + mock_post.return_value = request + mock_get.return_value = request + + # Variation Initializations + obj = plugins.NotifySlack(access_token=token, targets='user@gmail.com') + assert isinstance(obj, plugins.NotifySlack) is True + assert isinstance(obj.url(), six.string_types) is True + + # No calls made yet + assert mock_post.call_count == 0 + assert mock_get.call_count == 0 + + # Send our notification + assert obj.notify( + body='body', title='title', notify_type=NotifyType.INFO) is True + + # 2 calls were made, one to perform an email lookup, the second + # was the notification itself + assert mock_get.call_count == 1 + assert mock_post.call_count == 1 + assert mock_get.call_args_list[0][0][0] == \ + 'https://slack.com/api/users.lookupByEmail' + assert mock_post.call_args_list[0][0][0] == \ + 'https://slack.com/api/chat.postMessage' + + # Reset our mock object + mock_post.reset_mock() + mock_get.reset_mock() + + # Prepare Mock + mock_post.return_value = request + mock_get.return_value = request + + # Send our notification again (cached copy of user id associated with + # email is used) + assert obj.notify( + body='body', title='title', notify_type=NotifyType.INFO) is True + + assert mock_get.call_count == 0 + assert mock_post.call_count == 1 + assert mock_post.call_args_list[0][0][0] == \ + 'https://slack.com/api/chat.postMessage' + + # + # Now test a case where we can't look up the valid email + # + request.content = dumps({ + 'ok': False, + 'message': '', + }) + + # Reset our mock object + mock_post.reset_mock() + mock_get.reset_mock() + + # Prepare Mock + mock_post.return_value = request + mock_get.return_value = request + + # Variation Initializations + obj = plugins.NotifySlack(access_token=token, targets='user@gmail.com') + assert isinstance(obj, plugins.NotifySlack) is True + assert isinstance(obj.url(), six.string_types) is True + + # No calls made yet + assert mock_post.call_count == 0 + assert mock_get.call_count == 0 + + # Send our notification; it will fail because we failed to look up + # the user id + assert obj.notify( + body='body', title='title', notify_type=NotifyType.INFO) is False + + # We would have failed to look up the email, therefore we wouldn't have + # even bothered to attempt to send the notification + assert mock_get.call_count == 1 + assert mock_post.call_count == 0 + assert mock_get.call_args_list[0][0][0] == \ + 'https://slack.com/api/users.lookupByEmail' + + # + # Now test a case where we have a poorly formatted JSON response + # + request.content = '}' + + # Reset our mock object + mock_post.reset_mock() + mock_get.reset_mock() + + # Prepare Mock + mock_post.return_value = request + mock_get.return_value = request + + # Variation Initializations + obj = plugins.NotifySlack(access_token=token, targets='user@gmail.com') + assert isinstance(obj, plugins.NotifySlack) is True + assert isinstance(obj.url(), six.string_types) is True + + # No calls made yet + assert mock_post.call_count == 0 + assert mock_get.call_count == 0 + + # Send our notification; it will fail because we failed to look up + # the user id + assert obj.notify( + body='body', title='title', notify_type=NotifyType.INFO) is False + + # We would have failed to look up the email, therefore we wouldn't have + # even bothered to attempt to send the notification + assert mock_get.call_count == 1 + assert mock_post.call_count == 0 + assert mock_get.call_args_list[0][0][0] == \ + 'https://slack.com/api/users.lookupByEmail' + + # + # Now test a case where we have a poorly formatted JSON response + # + request.content = '}' + + # Reset our mock object + mock_post.reset_mock() + mock_get.reset_mock() + + # Prepare Mock + mock_post.return_value = request + mock_get.return_value = request + + # Variation Initializations + obj = plugins.NotifySlack(access_token=token, targets='user@gmail.com') + assert isinstance(obj, plugins.NotifySlack) is True + assert isinstance(obj.url(), six.string_types) is True + + # No calls made yet + assert mock_post.call_count == 0 + assert mock_get.call_count == 0 + + # Send our notification; it will fail because we failed to look up + # the user id + assert obj.notify( + body='body', title='title', notify_type=NotifyType.INFO) is False + + # We would have failed to look up the email, therefore we wouldn't have + # even bothered to attempt to send the notification + assert mock_get.call_count == 1 + assert mock_post.call_count == 0 + assert mock_get.call_args_list[0][0][0] == \ + 'https://slack.com/api/users.lookupByEmail' + + # + # Now test a case where we throw an exception trying to perform the lookup + # + + request.content = dumps({ + 'ok': True, + 'message': '', + 'user': { + 'id': 'ABCD1234' + } + }) + # Create an unauthorized response + request.status_code = requests.codes.ok + + # Reset our mock object + mock_post.reset_mock() + mock_get.reset_mock() + + # Prepare Mock + mock_post.return_value = request + mock_get.side_effect = requests.ConnectionError( + 0, 'requests.ConnectionError() not handled') + + # Variation Initializations + obj = plugins.NotifySlack(access_token=token, targets='user@gmail.com') + assert isinstance(obj, plugins.NotifySlack) is True + assert isinstance(obj.url(), six.string_types) is True + + # No calls made yet + assert mock_post.call_count == 0 + assert mock_get.call_count == 0 + + # Send our notification; it will fail because we failed to look up + # the user id + assert obj.notify( + body='body', title='title', notify_type=NotifyType.INFO) is False + + # We would have failed to look up the email, therefore we wouldn't have + # even bothered to attempt to send the notification + assert mock_get.call_count == 1 + assert mock_post.call_count == 0 + assert mock_get.call_args_list[0][0][0] == \ + 'https://slack.com/api/users.lookupByEmail'