mirror of https://github.com/caronc/apprise
Added support for Slack email address targets (#345)
parent
ca3629f2e1
commit
23957a3337
|
@ -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']):
|
||||
|
|
117
test/test_api.py
117
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
|
||||
|
|
|
@ -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',
|
||||
])
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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'
|
||||
|
|
Loading…
Reference in New Issue