Added support for Slack email address targets (#345)

pull/346/head
Chris Caron 2021-01-10 15:55:59 -05:00 committed by GitHub
parent ca3629f2e1
commit 23957a3337
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
5 changed files with 659 additions and 151 deletions

View File

@ -43,7 +43,7 @@
# to add a 'Bot User'. Give it a name and choose 'Add Bot User'. # 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 # 4. Now you can choose 'Install App' to which you can choose 'Install App
# to Workspace'. # 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 # 6. Finally you'll get some important information providing you your
# 'OAuth Access Token' and 'Bot User OAuth Access Token' such as: # 'OAuth Access Token' and 'Bot User OAuth Access Token' such as:
# slack://{Oauth Access Token} # slack://{Oauth Access Token}
@ -53,6 +53,21 @@
# ... or: # ... or:
# slack://xoxb-1234-1234-4ddbc191d40ee098cbaae6f3523ada2d # 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 re
import requests import requests
@ -64,6 +79,7 @@ from .NotifyBase import NotifyBase
from ..common import NotifyImageSize from ..common import NotifyImageSize
from ..common import NotifyType from ..common import NotifyType
from ..common import NotifyFormat from ..common import NotifyFormat
from ..utils import is_email
from ..utils import parse_bool from ..utils import parse_bool
from ..utils import parse_list from ..utils import parse_list
from ..utils import validate_regex from ..utils import validate_regex
@ -202,6 +218,11 @@ class NotifySlack(NotifyBase):
'prefix': '+', 'prefix': '+',
'map_to': 'targets', 'map_to': 'targets',
}, },
'target_email': {
'name': _('Target Email'),
'type': 'string',
'map_to': 'targets',
},
'target_user': { 'target_user': {
'name': _('Target User'), 'name': _('Target User'),
'type': 'string', 'type': 'string',
@ -237,6 +258,10 @@ class NotifySlack(NotifyBase):
'to': { 'to': {
'alias_of': 'targets', '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, def __init__(self, access_token=None, token_a=None, token_b=None,
@ -287,6 +312,11 @@ class NotifySlack(NotifyBase):
self.logger.warning( self.logger.warning(
'No user was specified; using "%s".' % self.app_id) '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 # Build list of channels
self.channels = parse_list(targets) self.channels = parse_list(targets)
if len(self.channels) == 0: if len(self.channels) == 0:
@ -382,30 +412,42 @@ class NotifySlack(NotifyBase):
channel = channels.pop(0) channel = channels.pop(0)
if channel is not None: if channel is not None:
_channel = validate_regex( channel = validate_regex(channel, r'[+#@]?[A-Z0-9_]{1,32}')
channel, r'[+#@]?(?P<value>[A-Z0-9_]{1,32})') if not channel:
if not _channel:
# Channel over-ride was specified # Channel over-ride was specified
self.logger.warning( self.logger.warning(
"The specified target {} is invalid;" "The specified target {} is invalid;"
"skipping.".format(_channel)) "skipping.".format(channel))
# Mark our failure # Mark our failure
has_error = True has_error = True
continue continue
if len(_channel) > 1 and _channel[0] == '+': if channel[0] == '+':
# Treat as encoded id if prefixed with a + # 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' # Treat @ value 'as is'
payload['channel'] = _channel payload['channel'] = channel
else: else:
# Prefix with channel hash tag # We'll perform a user lookup if we detect an email
payload['channel'] = '#{}'.format(_channel) 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 # Store the valid and massaged payload that is recognizable by
# slack. This list is used for sending attachments later. # slack. This list is used for sending attachments later.
@ -465,6 +507,162 @@ class NotifySlack(NotifyBase):
return not has_error 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): def _send(self, url, payload, attach=None, **kwargs):
""" """
Wrapper to the requests (post) object Wrapper to the requests (post) object
@ -477,6 +675,7 @@ class NotifySlack(NotifyBase):
headers = { headers = {
'User-Agent': self.app_id, 'User-Agent': self.app_id,
'Accept': 'application/json',
} }
if not attach: if not attach:
@ -486,7 +685,7 @@ class NotifySlack(NotifyBase):
headers['Authorization'] = 'Bearer {}'.format(self.access_token) headers['Authorization'] = 'Bearer {}'.format(self.access_token)
# Our response object # Our response object
response = None response = {'ok': False}
# Always call throttle before any remote server i/o is made # Always call throttle before any remote server i/o is made
self.throttle() self.throttle()
@ -508,7 +707,28 @@ class NotifySlack(NotifyBase):
timeout=self.request_timeout, 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 # We had a problem
status_str = \ status_str = \
NotifySlack.http_response_code_lookup( NotifySlack.http_response_code_lookup(
@ -526,30 +746,6 @@ class NotifySlack(NotifyBase):
'Response Details:\r\n{}'.format(r.content)) 'Response Details:\r\n{}'.format(r.content))
return False 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: # Message Post Response looks like this:
# { # {
# "attachments": [ # "attachments": [
@ -658,14 +854,14 @@ class NotifySlack(NotifyBase):
# Extend our parameters # Extend our parameters
params.update(self.url_parameters(privacy=privacy, *args, **kwargs)) params.update(self.url_parameters(privacy=privacy, *args, **kwargs))
if self.mode == SlackMode.WEBHOOK: # Determine if there is a botname present
# Determine if there is a botname present botname = ''
botname = '' if self.user:
if self.user: botname = '{botname}@'.format(
botname = '{botname}@'.format( botname=NotifySlack.quote(self.user, safe=''),
botname=NotifySlack.quote(self.user, safe=''), )
)
if self.mode == SlackMode.WEBHOOK:
return '{schema}://{botname}{token_a}/{token_b}/{token_c}/'\ return '{schema}://{botname}{token_a}/{token_b}/{token_c}/'\
'{targets}/?{params}'.format( '{targets}/?{params}'.format(
schema=self.secure_protocol, schema=self.secure_protocol,
@ -679,9 +875,10 @@ class NotifySlack(NotifyBase):
params=NotifySlack.urlencode(params), params=NotifySlack.urlencode(params),
) )
# else -> self.mode == SlackMode.BOT: # else -> self.mode == SlackMode.BOT:
return '{schema}://{access_token}/{targets}/'\ return '{schema}://{botname}{access_token}/{targets}/'\
'?{params}'.format( '?{params}'.format(
schema=self.secure_protocol, schema=self.secure_protocol,
botname=botname,
access_token=self.pprint(self.access_token, privacy, safe=''), access_token=self.pprint(self.access_token, privacy, safe=''),
targets='/'.join( targets='/'.join(
[NotifySlack.quote(x, safe='') for x in self.channels]), [NotifySlack.quote(x, safe='') for x in self.channels]),
@ -714,25 +911,36 @@ class NotifySlack(NotifyBase):
else: else:
# We're dealing with a webhook # We're dealing with a webhook
results['token_a'] = token results['token_a'] = token
results['token_b'] = entries.pop(0) if entries else None
# Now fetch the remaining tokens results['token_c'] = entries.pop(0) if entries else None
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
# assign remaining entries to the channels we wish to notify # assign remaining entries to the channels we wish to notify
results['targets'] = entries 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 # Support the 'to' variable so that we can support rooms this way too
# The 'to' makes it easier to use yaml configuration # The 'to' makes it easier to use yaml configuration
if 'to' in results['qsd'] and len(results['qsd']['to']): if 'to' in results['qsd'] and len(results['qsd']['to']):

View File

@ -1270,59 +1270,90 @@ def test_apprise_details_plugin_verification():
assert len(arg['delim']) > 0 assert len(arg['delim']) > 0
else: # alias_of is in the object 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 # Ensure we're not already in the tokens section
# The alias_of object has no value here # The alias_of object has no value here
assert section != 'tokens' assert section != 'tokens'
# We can't be an alias_of ourselves # must be a string
if key == arg['alias_of']: assert isinstance(
# This is acceptable as long as we exist in the tokens arg['alias_of'], (six.string_types, list, tuple, set))
# table because that is truely what we map back to
assert key in entry['details']['tokens']
else: aliases = [arg['alias_of']] \
# Throw the problem into an assert tag for debugging if isinstance(arg['alias_of'], six.string_types) \
# purposes... the mapping is not acceptable else arg['alias_of']
assert key != arg['alias_of']
# alias_of always references back to tokens for alias_of in aliases:
assert \ # Track our alias_of object
arg['alias_of'] in entry['details']['tokens'] or \ map_to_aliases.add(alias_of)
arg['alias_of'] in entry['details']['args']
# Find a list directive in our tokens # We can't be an alias_of ourselves
t_match = entry['details']['tokens']\ if key == alias_of:
.get(arg['alias_of'], {})\ # This is acceptable as long as we exist in the
.get('type', '').startswith('list') # tokens table because that is truely what we map
# back to
assert key in entry['details']['tokens']
a_match = entry['details']['args']\ else:
.get(arg['alias_of'], {})\ # Throw the problem into an assert tag for
.get('type', '').startswith('list') # debugging purposes... the mapping is not
# acceptable
assert key != alias_of
if not (t_match or a_match): # alias_of always references back to tokens
# Ensure the only token we have is the alias_of assert \
assert len(entry['details'][section][key]) == 1 alias_of in entry['details']['tokens'] or \
alias_of in entry['details']['args']
else: # Find a list directive in our tokens
# We're a list, we allow up to 2 variables t_match = entry['details']['tokens']\
# Obviously we have the alias_of entry; that's why .get(alias_of, {})\
# were at this part of the code. But we can .get('type', '').startswith('list')
# additionally provide a 'delim' over-ride.
assert len(entry['details'][section][key]) <= 2 a_match = entry['details']['args']\
if len(entry['details'][section][key]) == 2: .get(alias_of, {})\
# Verify that it is in fact the 'delim' tag .get('type', '').startswith('list')
assert 'delim' in entry['details'][section][key]
# If we do have a delim value set, it must be of if not (t_match or a_match):
# a list/set/tuple type # Ensure the only token we have is the alias_of
assert isinstance( # hence record should look like as example):
entry['details'][section][key]['delim'], # {
(tuple, set, list), # '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: if six.PY2:
# inspect our object # inspect our object

View File

@ -433,9 +433,10 @@ def test_apprise_cli_nux_env(tmpdir):
with mock.patch('apprise.cli.DEFAULT_SEARCH_PATHS', []): with mock.patch('apprise.cli.DEFAULT_SEARCH_PATHS', []):
with environ(APPRISE_URLS=" "): with environ(APPRISE_URLS=" "):
# An empty string is not valid and therefore not loaded so the below # An empty string is not valid and therefore not loaded so the
# fails. We override the DEFAULT_SEARCH_PATHS because we don't # below fails. We override the DEFAULT_SEARCH_PATHS because we
# want to detect ones loaded on the machine running the unit tests # don't want to detect ones loaded on the machine running the unit
# tests
result = runner.invoke(cli.main, [ result = runner.invoke(cli.main, [
'-b', 'test environment', '-b', 'test environment',
]) ])

View File

@ -4070,19 +4070,13 @@ TEST_URLS = (
'instance': plugins.NotifySlack, 'instance': plugins.NotifySlack,
# don't include an image by default # don't include an image by default
'include_image': False, 'include_image': False,
'requests_response_text': { 'requests_response_text': 'ok'
'ok': True,
'message': '',
},
}), }),
('slack://T1JJ3T3L2/A1BRTD4JD/TIiajkdnlazkcOXrIdevi7FQ/+id/@id/', { ('slack://T1JJ3T3L2/A1BRTD4JD/TIiajkdnlazkcOXrIdevi7FQ/+id/@id/', {
# + encoded id, # + encoded id,
# @ userid # @ userid
'instance': plugins.NotifySlack, 'instance': plugins.NotifySlack,
'requests_response_text': { 'requests_response_text': 'ok',
'ok': True,
'message': '',
},
}), }),
('slack://username@T1JJ3T3L2/A1BRTD4JD/TIiajkdnlazkcOXrIdevi7FQ/' ('slack://username@T1JJ3T3L2/A1BRTD4JD/TIiajkdnlazkcOXrIdevi7FQ/'
'?to=#nuxref', { '?to=#nuxref', {
@ -4090,17 +4084,35 @@ TEST_URLS = (
# Our expected url(privacy=True) startswith() response: # Our expected url(privacy=True) startswith() response:
'privacy_url': 'slack://username@T...2/A...D/T...Q/', 'privacy_url': 'slack://username@T...2/A...D/T...Q/',
'requests_response_text': { 'requests_response_text': 'ok',
'ok': True,
'message': '',
},
}), }),
('slack://username@T1JJ3T3L2/A1BRTD4JD/TIiajkdnlazkcOXrIdevi7FQ/#nuxref', { ('slack://username@T1JJ3T3L2/A1BRTD4JD/TIiajkdnlazkcOXrIdevi7FQ/#nuxref', {
'instance': plugins.NotifySlack, 'instance': plugins.NotifySlack,
'requests_response_text': { 'requests_response_text': 'ok',
'ok': True, }),
'message': '', # 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) # Test using a bot-token (also test footer set to no flag)
('slack://username@xoxb-1234-1234-abc124/#nuxref?footer=no', { ('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', { ('slack://username@xoxb-1234-1234-abc124/#nuxref', {
'instance': plugins.NotifySlack, 'instance': plugins.NotifySlack,
'requests_response_text': { 'requests_response_text': {
@ -4129,28 +4168,19 @@ TEST_URLS = (
('slack://username@T1JJ3T3L2/A1BRTD4JD/TIiajkdnlazkcOXrIdevi7FQ', { ('slack://username@T1JJ3T3L2/A1BRTD4JD/TIiajkdnlazkcOXrIdevi7FQ', {
# Missing a channel, falls back to webhook channel bindings # Missing a channel, falls back to webhook channel bindings
'instance': plugins.NotifySlack, 'instance': plugins.NotifySlack,
'requests_response_text': { 'requests_response_text': 'ok',
'ok': True,
'message': '',
},
}), }),
# Native URL Support, take the slack URL and still build from it # Native URL Support, take the slack URL and still build from it
('https://hooks.slack.com/services/{}/{}/{}'.format( ('https://hooks.slack.com/services/{}/{}/{}'.format(
'A' * 9, 'B' * 9, 'c' * 24), { 'A' * 9, 'B' * 9, 'c' * 24), {
'instance': plugins.NotifySlack, 'instance': plugins.NotifySlack,
'requests_response_text': { 'requests_response_text': 'ok',
'ok': True,
'message': '',
},
}), }),
# Native URL Support with arguments # Native URL Support with arguments
('https://hooks.slack.com/services/{}/{}/{}?format=text'.format( ('https://hooks.slack.com/services/{}/{}/{}?format=text'.format(
'A' * 9, 'B' * 9, 'c' * 24), { 'A' * 9, 'B' * 9, 'c' * 24), {
'instance': plugins.NotifySlack, 'instance': plugins.NotifySlack,
'requests_response_text': { 'requests_response_text': 'ok',
'ok': True,
'message': '',
},
}), }),
('slack://username@-INVALID-/A1BRTD4JD/TIiajkdnlazkcOXrIdevi7FQ/#cool', { ('slack://username@-INVALID-/A1BRTD4JD/TIiajkdnlazkcOXrIdevi7FQ/#cool', {
# invalid 1st Token # invalid 1st Token
@ -4169,30 +4199,21 @@ TEST_URLS = (
# force a failure # force a failure
'response': False, 'response': False,
'requests_response_code': requests.codes.internal_server_error, 'requests_response_code': requests.codes.internal_server_error,
'requests_response_text': { 'requests_response_text': 'ok',
'ok': False,
'message': '',
},
}), }),
('slack://respect@T1JJ3T3L2/A1BRTD4JD/TIiajkdnlazkcOXrIdevi7FQ/#a', { ('slack://respect@T1JJ3T3L2/A1BRTD4JD/TIiajkdnlazkcOXrIdevi7FQ/#a', {
'instance': plugins.NotifySlack, 'instance': plugins.NotifySlack,
# throw a bizzare code forcing us to fail to look it up # throw a bizzare code forcing us to fail to look it up
'response': False, 'response': False,
'requests_response_code': 999, 'requests_response_code': 999,
'requests_response_text': { 'requests_response_text': 'ok',
'ok': False,
'message': '',
},
}), }),
('slack://notify@T1JJ3T3L2/A1BRTD4JD/TIiajkdnlazkcOXrIdevi7FQ/#b', { ('slack://notify@T1JJ3T3L2/A1BRTD4JD/TIiajkdnlazkcOXrIdevi7FQ/#b', {
'instance': plugins.NotifySlack, 'instance': plugins.NotifySlack,
# Throws a series of connection and transfer exceptions when this flag # Throws a series of connection and transfer exceptions when this flag
# is set and tests that we gracfully handle them # is set and tests that we gracfully handle them
'test_requests_exceptions': True, 'test_requests_exceptions': True,
'requests_response_text': { 'requests_response_text': 'ok',
'ok': False,
'message': '',
},
}), }),
################################## ##################################
@ -5194,6 +5215,8 @@ def test_rest_plugins(mock_post, mock_get):
# Handle our default text response # Handle our default text response
mock_get.return_value.content = requests_response_text mock_get.return_value.content = requests_response_text
mock_post.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 # Ensure there is no side effect set
mock_post.side_effect = None mock_post.side_effect = None

View File

@ -85,12 +85,39 @@ def test_slack_oauth_access_token(mock_post):
assert obj.send(body="test") is True assert obj.send(body="test") is True
# Test Valid Attachment # Test Valid Attachment
mock_post.reset_mock()
path = os.path.join(TEST_VAR_DIR, 'apprise-test.gif') path = os.path.join(TEST_VAR_DIR, 'apprise-test.gif')
attach = AppriseAttachment(path) attach = AppriseAttachment(path)
assert obj.notify( assert obj.notify(
body='body', title='title', notify_type=NotifyType.INFO, body='body', title='title', notify_type=NotifyType.INFO,
attach=attach) is True 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 # Test invalid attachment
path = os.path.join(TEST_VAR_DIR, '/invalid/path/to/an/invalid/file.jpg') path = os.path.join(TEST_VAR_DIR, '/invalid/path/to/an/invalid/file.jpg')
assert obj.notify( assert obj.notify(
@ -135,6 +162,8 @@ def test_slack_oauth_access_token(mock_post):
# We'll fail now because of an internal exception # We'll fail now because of an internal exception
assert obj.send(body="test") is False assert obj.send(body="test") is False
# Test Email Lookup
@mock.patch('requests.post') @mock.patch('requests.post')
def test_slack_webhook(mock_post): def test_slack_webhook(mock_post):
@ -148,9 +177,8 @@ def test_slack_webhook(mock_post):
# Prepare Mock # Prepare Mock
mock_post.return_value = requests.Request() mock_post.return_value = requests.Request()
mock_post.return_value.status_code = requests.codes.ok mock_post.return_value.status_code = requests.codes.ok
mock_post.return_value.content = dumps({ mock_post.return_value.content = 'ok'
'ok': True, mock_post.return_value.text = 'ok'
})
# Initialize some generic (but valid) tokens # Initialize some generic (but valid) tokens
token_a = 'A' * 9 token_a = 'A' * 9
@ -182,3 +210,220 @@ def test_slack_webhook(mock_post):
# This call includes an image with it's payload: # This call includes an image with it's payload:
assert obj.notify( assert obj.notify(
body='body', title='title', notify_type=NotifyType.INFO) is True 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'