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'.
# 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<value>[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']):

View File

@ -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

View File

@ -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',
])

View File

@ -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

View File

@ -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'