diff --git a/apprise/plugins/NotifySlack.py b/apprise/plugins/NotifySlack.py index 02d965ea8b..c3a3b172b0 100644 --- a/apprise/plugins/NotifySlack.py +++ b/apprise/plugins/NotifySlack.py @@ -23,21 +23,41 @@ # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN # THE SOFTWARE. -# To use this plugin, you need to first access https://api.slack.com -# Specifically https://my.slack.com/services/new/incoming-webhook/ -# to create a new incoming webhook for your account. You'll need to -# follow the wizard to pre-determine the channel(s) you want your -# message to broadcast to, and when you're complete, you will -# recieve a URL that looks something like this: -# https://hooks.slack.com/services/T1JJ3T3L2/A1BRTD4JD/TIiajkdnlazkcOXrIdevi7FQ -# ^ ^ ^ -# | | | -# These are important <--------------^---------^---------------^ +# There are 2 ways to use this plugin... +# Method 1: Via Webhook: +# Visit https://my.slack.com/services/new/incoming-webhook/ +# to create a new incoming webhook for your account. You'll need to +# follow the wizard to pre-determine the channel(s) you want your +# message to broadcast to, and when you're complete, you will +# recieve a URL that looks something like this: +# https://hooks.slack.com/services/T1JJ3T3L2/A1BRTD4JD/TIiajkdnlazkcOXrIdevi7 +# ^ ^ ^ +# | | | +# These are important <--------------^---------^---------------^ # +# Method 2: Via a Bot: +# 1. visit: https://api.slack.com/apps?new_app=1 +# 2. Pick an App Name (such as Apprise) and select your workspace. Then +# press 'Create App' +# 3. You'll be able to click on 'Bots' from here where you can then choose +# 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. +# 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} # +# ... which might look something like: +# slack://xoxp-1234-1234-1234-4ddbc191d40ee098cbaae6f3523ada2d +# ... or: +# slack://xoxb-1234-1234-4ddbc191d40ee098cbaae6f3523ada2d +# + import re import requests from json import dumps +from json import loads from time import time from .NotifyBase import NotifyBase @@ -58,6 +78,27 @@ CHANNEL_LIST_DELIM = re.compile(r'[ \t\r\n,#\\/]+') +class SlackMode(object): + """ + Tracks the mode of which we're using Slack + """ + # We're dealing with a webhook + # Our token looks like: T1JJ3T3L2/A1BRTD4JD/TIiajkdnlazkcOXrIdevi7 + WEBHOOK = 'webhook' + + # We're dealing with a bot (using the OAuth Access Token) + # Our token looks like: xoxp-1234-1234-1234-abc124 or + # Our token looks like: xoxb-1234-1234-abc124 or + BOT = 'bot' + + +# Define our Slack Modes +SLACK_MODES = ( + SlackMode.WEBHOOK, + SlackMode.BOT, +) + + class NotifySlack(NotifyBase): """ A wrapper for Slack Notifications @@ -72,27 +113,43 @@ class NotifySlack(NotifyBase): # The default secure protocol secure_protocol = 'slack' + # Allow 50 requests per minute (Tier 2). + # 60/50 = 0.2 + request_rate_per_sec = 1.2 + # A URL that takes you to the setup/help of the specific protocol setup_url = 'https://github.com/caronc/apprise/wiki/Notify_slack' - # Slack uses the http protocol with JSON requests - notify_url = 'https://hooks.slack.com/services' + # Slack Webhook URL + webhook_url = 'https://hooks.slack.com/services' + + # Slack API URL (used with Bots) + api_url = 'https://slack.com/api/{}' # Allows the user to specify the NotifyImageSize object image_size = NotifyImageSize.XY_72 # The maximum allowable characters allowed in the body per message - body_maxlen = 1000 + body_maxlen = 35000 # Default Notification Format notify_format = NotifyFormat.MARKDOWN + # Bot's do not have default channels to notify; so #general + # becomes the default channel in BOT mode + default_notification_channel = '#general' + # Define object templates templates = ( + # Webhook '{schema}://{token_a}/{token_b}{token_c}', '{schema}://{botname}@{token_a}/{token_b}{token_c}', '{schema}://{token_a}/{token_b}{token_c}/{targets}', '{schema}://{botname}@{token_a}/{token_b}{token_c}/{targets}', + + # Bot + '{schema}://{access_token}/', + '{schema}://{access_token}/{targets}', ) # Define our template tokens @@ -102,7 +159,17 @@ class NotifySlack(NotifyBase): 'type': 'string', 'map_to': 'user', }, - # Token required as part of the API request + # Bot User OAuth Access Token + # which always starts with xoxp- e.g.: + # xoxb-1234-1234-4ddbc191d40ee098cbaae6f3523ada2d + 'access_token': { + 'name': _('OAuth Access Token'), + 'type': 'string', + 'private': True, + 'required': True, + 'regex': (r'^xox[abp]-[A-Z0-9-]+$', 'i'), + }, + # Token required as part of the Webhook request # /AAAAAAAAA/........./........................ 'token_a': { 'name': _('Token A'), @@ -111,7 +178,7 @@ class NotifySlack(NotifyBase): 'required': True, 'regex': (r'^[A-Z0-9]{9}$', 'i'), }, - # Token required as part of the API request + # Token required as part of the Webhook request # /........./BBBBBBBBB/........................ 'token_b': { 'name': _('Token B'), @@ -120,7 +187,7 @@ class NotifySlack(NotifyBase): 'required': True, 'regex': (r'^[A-Z0-9]{9}$', 'i'), }, - # Token required as part of the API request + # Token required as part of the Webhook request # /........./........./CCCCCCCCCCCCCCCCCCCCCCCC 'token_c': { 'name': _('Token C'), @@ -161,41 +228,60 @@ class NotifySlack(NotifyBase): 'default': True, 'map_to': 'include_image', }, + 'footer': { + 'name': _('Include Footer'), + 'type': 'bool', + 'default': True, + 'map_to': 'include_footer', + }, 'to': { 'alias_of': 'targets', }, }) - def __init__(self, token_a, token_b, token_c, targets, - include_image=True, **kwargs): + def __init__(self, access_token=None, token_a=None, token_b=None, + token_c=None, targets=None, include_image=True, + include_footer=True, **kwargs): """ Initialize Slack Object """ super(NotifySlack, self).__init__(**kwargs) - self.token_a = validate_regex( - token_a, *self.template_tokens['token_a']['regex']) - if not self.token_a: - msg = 'An invalid Slack (first) Token ' \ - '({}) was specified.'.format(token_a) - self.logger.warning(msg) - raise TypeError(msg) - - self.token_b = validate_regex( - token_b, *self.template_tokens['token_b']['regex']) - if not self.token_b: - msg = 'An invalid Slack (second) Token ' \ - '({}) was specified.'.format(token_b) - self.logger.warning(msg) - raise TypeError(msg) - - self.token_c = validate_regex( - token_c, *self.template_tokens['token_c']['regex']) - if not self.token_c: - msg = 'An invalid Slack (third) Token ' \ - '({}) was specified.'.format(token_c) - self.logger.warning(msg) - raise TypeError(msg) + # Setup our mode + self.mode = SlackMode.BOT if access_token else SlackMode.WEBHOOK + + if self.mode is SlackMode.WEBHOOK: + self.token_a = validate_regex( + token_a, *self.template_tokens['token_a']['regex']) + if not self.token_a: + msg = 'An invalid Slack (first) Token ' \ + '({}) was specified.'.format(token_a) + self.logger.warning(msg) + raise TypeError(msg) + + self.token_b = validate_regex( + token_b, *self.template_tokens['token_b']['regex']) + if not self.token_b: + msg = 'An invalid Slack (second) Token ' \ + '({}) was specified.'.format(token_b) + self.logger.warning(msg) + raise TypeError(msg) + + self.token_c = validate_regex( + token_c, *self.template_tokens['token_c']['regex']) + if not self.token_c: + msg = 'An invalid Slack (third) Token ' \ + '({}) was specified.'.format(token_c) + self.logger.warning(msg) + raise TypeError(msg) + else: + self.access_token = validate_regex( + access_token, *self.template_tokens['access_token']['regex']) + if not self.access_token: + msg = 'An invalid Slack OAuth Access Token ' \ + '({}) was specified.'.format(access_token) + self.logger.warning(msg) + raise TypeError(msg) if not self.user: self.logger.warning( @@ -207,7 +293,9 @@ def __init__(self, token_a, token_b, token_c, targets, # No problem; the webhook is smart enough to just notify the # channel it was created for; adding 'None' is just used as # a flag lower to not set the channels - self.channels.append(None) + self.channels.append( + None if self.mode is SlackMode.WEBHOOK + else self.default_notification_channel) # Formatting requirements are defined here: # https://api.slack.com/docs/message-formatting @@ -229,18 +317,16 @@ def __init__(self, token_a, token_b, token_c, targets, # Place a thumbnail image inline with the message body self.include_image = include_image + # Place a footer with each post + self.include_footer = include_footer return - def send(self, body, title='', notify_type=NotifyType.INFO, **kwargs): + def send(self, body, title='', notify_type=NotifyType.INFO, attach=None, + **kwargs): """ Perform Slack Notification """ - headers = { - 'User-Agent': self.app_id, - 'Content-Type': 'application/json', - } - # error tracking (used for function return) has_error = False @@ -251,14 +337,8 @@ def send(self, body, title='', notify_type=NotifyType.INFO, **kwargs): body = self._re_formatting_rules.sub( # pragma: no branch lambda x: self._re_formatting_map[x.group()], body, ) - url = '%s/%s/%s/%s' % ( - self.notify_url, - self.token_a, - self.token_b, - self.token_c, - ) - # prepare JSON Object + # Prepare JSON Object (applicable to both WEBHOOK and BOT mode) payload = { 'username': self.user if self.user else self.app_id, # Use Markdown language @@ -269,18 +349,41 @@ def send(self, body, title='', notify_type=NotifyType.INFO, **kwargs): 'color': self.color(notify_type), # Time 'ts': time(), - 'footer': self.app_id, }], } + # Prepare our URL (depends on mode) + if self.mode is SlackMode.WEBHOOK: + url = '{}/{}/{}/{}'.format( + self.webhook_url, + self.token_a, + self.token_b, + self.token_c, + ) + + else: # SlackMode.BOT + url = self.api_url.format('chat.postMessage') + + if self.include_footer: + # Include the footer only if specified to do so + payload['attachments'][0]['footer'] = self.app_id + + if attach and self.mode is SlackMode.WEBHOOK: + # Be friendly; let the user know why they can't send their + # attachments if using the Webhook mode + self.logger.warning( + 'Slack Webhooks do not support attachments.') + # Create a copy of the channel list channels = list(self.channels) + + attach_channel_list = [] while len(channels): channel = channels.pop(0) if channel is not None: _channel = validate_regex( - channel, r'[+#@]?([A-Z0-9_]{1,32})') + channel, r'[+#@]?(?P[A-Z0-9_]{1,32})') if not _channel: # Channel over-ride was specified @@ -304,69 +407,215 @@ def send(self, body, title='', notify_type=NotifyType.INFO, **kwargs): # Prefix with channel hash tag payload['channel'] = '#{}'.format(_channel) + # Store the valid and massaged payload that is recognizable by + # slack. This list is used for sending attachments later. + attach_channel_list.append(payload['channel']) + # Acquire our to-be footer icon if configured to do so image_url = None if not self.include_image \ else self.image_url(notify_type) if image_url: - payload['attachments'][0]['footer_icon'] = image_url payload['icon_url'] = image_url - self.logger.debug('Slack POST URL: %s (cert_verify=%r)' % ( - url, self.verify_certificate, - )) - self.logger.debug('Slack Payload: %s' % str(payload)) + if self.include_footer: + payload['attachments'][0]['footer_icon'] = image_url - # Always call throttle before any remote server i/o is made - self.throttle() - try: - r = requests.post( - url, - data=dumps(payload), - headers=headers, - verify=self.verify_certificate, - ) - if r.status_code != requests.codes.ok: - # We had a problem - status_str = \ - NotifySlack.http_response_code_lookup( - r.status_code, SLACK_HTTP_ERROR_MAP) + response = self._send(url, payload) + if not response: + # Handle any error + has_error = True + continue - self.logger.warning( - 'Failed to send Slack notification{}: ' - '{}{}error={}.'.format( - ' to {}'.format(channel) - if channel is not None else '', - status_str, - ', ' if status_str else '', - r.status_code)) + self.logger.info( + 'Sent Slack notification{}.'.format( + ' to {}'.format(channel) + if channel is not None else '')) - self.logger.debug( - 'Response Details:\r\n{}'.format(r.content)) + if attach and self.mode is SlackMode.BOT and attach_channel_list: + # Send our attachments (can only be done in bot mode) + for attachment in attach: + self.logger.info( + 'Posting Slack Attachment {}'.format(attachment.name)) - # Mark our failure - has_error = True - continue + # Prepare API Upload Payload + _payload = { + 'filename': attachment.name, + 'channels': ','.join(attach_channel_list) + } - else: - self.logger.info( - 'Sent Slack notification{}.'.format( - ' to {}'.format(channel) - if channel is not None else '')) + # Our URL + _url = self.api_url.format('files.upload') + + response = self._send(_url, _payload, attach=attachment) + if not (response and response.get('file') and + response['file'].get('url_private')): + # We failed to post our attachments, take an early exit + return False + + return not has_error + + def _send(self, url, payload, attach=None, auto_join=False, **kwargs): + """ + Wrapper to the requests (post) object + + auto_join will attempt to join a channel if it has been reported + we are not currently part of one. + """ + + self.logger.debug('Slack POST URL: %s (cert_verify=%r)' % ( + url, self.verify_certificate, + )) + self.logger.debug('Slack Payload: %s' % str(payload)) + + headers = { + 'User-Agent': self.app_id, + } + + if not attach: + headers['Content-Type'] = 'application/json; charset=utf-8' + + if self.mode is SlackMode.BOT: + headers['Authorization'] = 'Bearer {}'.format(self.access_token) + + # Our response object + response = None + + # Always call throttle before any remote server i/o is made + self.throttle() + try: + r = requests.post( + url, + data=payload if attach else dumps(payload), + headers=headers, + files=None if not attach + else {'file': open(attach.path, 'rb')}, + verify=self.verify_certificate, + ) + + if r.status_code != requests.codes.ok: + # We had a problem + status_str = \ + NotifySlack.http_response_code_lookup( + r.status_code, SLACK_HTTP_ERROR_MAP) - except requests.RequestException as e: self.logger.warning( - 'A Connection error occured sending Slack ' - 'notification{}.'.format( - ' to {}'.format(channel) - if channel is not None else '')) - self.logger.debug('Socket Exception: %s' % str(e)) + 'Failed to send {}to Slack: ' + '{}{}error={}.'.format( + attach.name if attach else '', + status_str, + ', ' if status_str else '', + r.status_code)) - # Mark our failure - has_error = True - continue + self.logger.debug( + 'Response Details:\r\n{}'.format(r.content)) + return False - return not has_error + try: + response = loads(r.content) + + except (AttributeError, ValueError): + # AttributeError means r.content was 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 + + # Message Post Response looks like this: + # { + # "attachments": [ + # { + # "color": "3AA3E3", + # "fallback": "test", + # "id": 1, + # "text": "my body", + # "title": "my title", + # "ts": 1573694687 + # } + # ], + # "bot_id": "BAK4K23G5", + # "icons": { + # "image_48": "https://s3-us-west-2.amazonaws.com/... + # }, + # "subtype": "bot_message", + # "text": "", + # "ts": "1573694689.003700", + # "type": "message", + # "username": "Apprise" + # } + + # File Attachment Responses look like this + # { + # "file": { + # "channels": [], + # "comments_count": 0, + # "created": 1573617523, + # "display_as_bot": false, + # "editable": false, + # "external_type": "", + # "filetype": "png", + # "groups": [], + # "has_rich_preview": false, + # "id": "FQJJLDAHM", + # "image_exif_rotation": 1, + # "ims": [], + # "is_external": false, + # "is_public": false, + # "is_starred": false, + # "mimetype": "image/png", + # "mode": "hosted", + # "name": "apprise-test.png", + # "original_h": 640, + # "original_w": 640, + # "permalink": "https://{name}.slack.com/files/... + # "permalink_public": "https://slack-files.com/... + # "pretty_type": "PNG", + # "public_url_shared": false, + # "shares": {}, + # "size": 238810, + # "thumb_160": "https://files.slack.com/files-tmb/... + # "thumb_360": "https://files.slack.com/files-tmb/... + # "thumb_360_h": 360, + # "thumb_360_w": 360, + # "thumb_480": "https://files.slack.com/files-tmb/... + # "thumb_480_h": 480, + # "thumb_480_w": 480, + # "thumb_64": "https://files.slack.com/files-tmb/... + # "thumb_80": "https://files.slack.com/files-tmb/... + # "thumb_tiny": abcd... + # "timestamp": 1573617523, + # "title": "apprise-test", + # "url_private": "https://files.slack.com/files-pri/... + # "url_private_download": "https://files.slack.com/files-... + # "user": "UADKLLMJT", + # "username": "" + # }, + # "ok": true + # } + except requests.RequestException as e: + self.logger.warning( + 'A Connection error occured posting {}to Slack.'.format( + attach.name if attach else '')) + self.logger.debug('Socket Exception: %s' % str(e)) + return False + + except (OSError, IOError) as e: + self.logger.warning( + 'An I/O error occured while reading {}.'.format( + attach.name if attach else 'attachment')) + self.logger.debug('I/O Exception: %s' % str(e)) + return False + + # Return the response for processing + return response def url(self, privacy=False, *args, **kwargs): """ @@ -378,23 +627,35 @@ def url(self, privacy=False, *args, **kwargs): 'format': self.notify_format, 'overflow': self.overflow_mode, 'image': 'yes' if self.include_image else 'no', + 'footer': 'yes' if self.include_footer else 'no', 'verify': 'yes' if self.verify_certificate else 'no', } - # 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: + # Determine if there is a botname present + botname = '' + if self.user: + botname = '{botname}@'.format( + botname=NotifySlack.quote(self.user, safe=''), + ) - return '{schema}://{botname}{token_a}/{token_b}/{token_c}/{targets}/'\ + return '{schema}://{botname}{token_a}/{token_b}/{token_c}/'\ + '{targets}/?{args}'.format( + schema=self.secure_protocol, + botname=botname, + token_a=self.pprint(self.token_a, privacy, safe=''), + token_b=self.pprint(self.token_b, privacy, safe=''), + token_c=self.pprint(self.token_c, privacy, safe=''), + targets='/'.join( + [NotifySlack.quote(x, safe='') + for x in self.channels]), + args=NotifySlack.urlencode(args), + ) + # else -> self.mode == SlackMode.BOT: + return '{schema}://{access_token}/{targets}/'\ '?{args}'.format( schema=self.secure_protocol, - botname=botname, - token_a=self.pprint(self.token_a, privacy, safe=''), - token_b=self.pprint(self.token_b, privacy, safe=''), - token_c=self.pprint(self.token_c, privacy, safe=''), + access_token=self.pprint(self.access_token, privacy, safe=''), targets='/'.join( [NotifySlack.quote(x, safe='') for x in self.channels]), args=NotifySlack.urlencode(args), @@ -407,32 +668,40 @@ def parse_url(url): us to substantiate this object. """ - results = NotifyBase.parse_url(url) - + results = NotifyBase.parse_url(url, verify_host=False) if not results: # We're done early as we couldn't load the results return results + # The first token is stored in the hostname + token = NotifySlack.unquote(results['host']) + # Get unquoted entries entries = NotifySlack.split_path(results['fullpath']) - # The first token is stored in the hostname - results['token_a'] = NotifySlack.unquote(results['host']) + # Verify if our token_a us a bot token or part of a webhook: + if token.startswith('xo'): + # We're dealing with a bot + results['access_token'] = token - # Now fetch the remaining tokens - try: - results['token_b'] = entries.pop(0) + else: + # We're dealing with a webhook + results['token_a'] = token - except IndexError: - # We're done - results['token_b'] = None + # Now fetch the remaining tokens + try: + results['token_b'] = entries.pop(0) - try: - results['token_c'] = entries.pop(0) + except IndexError: + # We're done + results['token_b'] = None - except IndexError: - # We're done - results['token_c'] = 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 results['targets'] = entries @@ -444,10 +713,14 @@ def parse_url(url): bool, CHANNEL_LIST_DELIM.split( NotifySlack.unquote(results['qsd']['to'])))] - # Get Image + # Get Image Flag results['include_image'] = \ parse_bool(results['qsd'].get('image', True)) + # Get Footer Flag + results['include_footer'] = \ + parse_bool(results['qsd'].get('footer', True)) + return results @staticmethod diff --git a/test/test_rest_plugins.py b/test/test_rest_plugins.py index bbe12ae8e9..e812f02de1 100644 --- a/test/test_rest_plugins.py +++ b/test/test_rest_plugins.py @@ -23,6 +23,7 @@ # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN # THE SOFTWARE. +import os import six import pytest import requests @@ -37,6 +38,7 @@ from apprise import NotifyBase from apprise import Apprise from apprise import AppriseAsset +from apprise import AppriseAttachment from apprise.common import NotifyFormat from apprise.common import OverflowMode @@ -61,6 +63,9 @@ 0, 'requests.TooManyRedirects() not handled'), ) +# Attachment Directory +TEST_VAR_DIR = os.path.join(os.path.dirname(__file__), 'var') + TEST_URLS = ( ################################## # NotifyBoxcar @@ -2483,10 +2488,10 @@ # NotifySlack ################################## ('slack://', { - 'instance': None, + 'instance': TypeError, }), ('slack://:@/', { - 'instance': None, + 'instance': TypeError, }), ('slack://T1JJ3T3L2', { # Just Token 1 provided @@ -2503,6 +2508,10 @@ # There is an invalid channel that we will fail to deliver to # as a result the response type will be false 'response': False, + 'requests_response_text': { + 'ok': False, + 'message': 'Bad Channel', + }, }), ('slack://T1JJ3T3L2/A1BRTD4JD/TIiajkdnlazkcOXrIdevi7FQ/#channel', { # No username specified; this is still okay as we sub in @@ -2510,11 +2519,19 @@ 'instance': plugins.NotifySlack, # don't include an image by default 'include_image': False, + 'requests_response_text': { + 'ok': True, + 'message': '', + }, }), ('slack://T1JJ3T3L2/A1BRTD4JD/TIiajkdnlazkcOXrIdevi7FQ/+id/@id/', { # + encoded id, # @ userid 'instance': plugins.NotifySlack, + 'requests_response_text': { + 'ok': True, + 'message': '', + }, }), ('slack://username@T1JJ3T3L2/A1BRTD4JD/TIiajkdnlazkcOXrIdevi7FQ/' \ '?to=#nuxref', { @@ -2522,23 +2539,67 @@ # Our expected url(privacy=True) startswith() response: 'privacy_url': 'slack://username@T...2/A...D/T...Q/', + 'requests_response_text': { + 'ok': True, + 'message': '', + }, }), ('slack://username@T1JJ3T3L2/A1BRTD4JD/TIiajkdnlazkcOXrIdevi7FQ/#nuxref', { 'instance': plugins.NotifySlack, + 'requests_response_text': { + 'ok': True, + 'message': '', + }, }), + # Test using a bot-token (also test footer set to no flag) + ('slack://username@xoxb-1234-1234-abc124/#nuxref?footer=no', { + 'instance': plugins.NotifySlack, + 'requests_response_text': { + 'ok': True, + 'message': '', + # support attachments + 'file': { + 'url_private': 'http://localhost/', + }, + }, + }), + + ('slack://username@xoxb-1234-1234-abc124/#nuxref', { + 'instance': plugins.NotifySlack, + 'requests_response_text': { + 'ok': True, + 'message': '', + }, + # we'll fail to send attachments because we had no 'file' response in + # our object + 'response': False, + }), + ('slack://username@T1JJ3T3L2/A1BRTD4JD/TIiajkdnlazkcOXrIdevi7FQ', { # Missing a channel, falls back to webhook channel bindings 'instance': plugins.NotifySlack, + 'requests_response_text': { + 'ok': True, + 'message': '', + }, }), # 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': '', + }, }), # 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': '', + }, }), ('slack://username@INVALID/A1BRTD4JD/TIiajkdnlazkcOXrIdevi7FQ/#cool', { # invalid 1st Token @@ -2557,18 +2618,30 @@ # force a failure 'response': False, 'requests_response_code': requests.codes.internal_server_error, + 'requests_response_text': { + 'ok': False, + 'message': '', + }, }), ('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': '', + }, }), ('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': '', + }, }), ################################## @@ -3613,6 +3686,26 @@ def test_rest_plugins(mock_post, mock_get): notify_type=notify_type, overflow=OverflowMode.SPLIT) == notify_response + # Test single attachment support; even if the service + # doesn't support attachments, it should still gracefully + # ignore the data + attach = os.path.join(TEST_VAR_DIR, 'apprise-test.gif') + assert obj.notify( + body=body, title=title, + notify_type=notify_type, + attach=attach) == notify_response + + # Same results should apply to a list of attachments + attach = AppriseAttachment(( + os.path.join(TEST_VAR_DIR, 'apprise-test.gif'), + os.path.join(TEST_VAR_DIR, 'apprise-test.png'), + os.path.join(TEST_VAR_DIR, 'apprise-test.jpeg'), + )) + assert obj.notify( + body=body, title=title, + notify_type=notify_type, + attach=attach) == notify_response + else: # Disable throttling obj.request_rate_per_sec = 0 @@ -4780,50 +4873,6 @@ def test_notify_sendgrid_plugin(mock_post, mock_get): cc=('abc@test.org', '!invalid')), plugins.NotifySendGrid) -@mock.patch('requests.get') -@mock.patch('requests.post') -def test_notify_slack_plugin(mock_post, mock_get): - """ - API: NotifySlack() Extra Checks - - """ - # Disable Throttling to speed testing - plugins.NotifyBase.request_rate_per_sec = 0 - - # Initialize some generic (but valid) tokens - token_a = 'A' * 9 - token_b = 'B' * 9 - token_c = 'c' * 24 - - # Support strings - channels = 'chan1,#chan2,+id,@user,,,' - - obj = plugins.NotifySlack( - token_a=token_a, token_b=token_b, token_c=token_c, targets=channels) - assert len(obj.channels) == 4 - - # Prepare Mock - mock_get.return_value = requests.Request() - mock_post.return_value = requests.Request() - mock_post.return_value.status_code = requests.codes.ok - mock_get.return_value.status_code = requests.codes.ok - - # Missing first Token - with pytest.raises(TypeError): - plugins.NotifySlack( - token_a=None, token_b=token_b, token_c=token_c, - targets=channels) - - # Test include_image - obj = plugins.NotifySlack( - token_a=token_a, token_b=token_b, token_c=token_c, targets=channels, - include_image=True) - - # 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.get') @mock.patch('requests.post') def test_notify_pushbullet_plugin(mock_post, mock_get): diff --git a/test/test_slack_plugin.py b/test/test_slack_plugin.py new file mode 100644 index 0000000000..f8d334c5e8 --- /dev/null +++ b/test/test_slack_plugin.py @@ -0,0 +1,145 @@ +# -*- coding: utf-8 -*- +# +# All rights reserved. +# +# This code is licensed under the MIT License. +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files(the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and / or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions : +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. + +import six +import mock +import pytest +import requests +from apprise import plugins +from apprise import NotifyType + +from json import dumps + +# Disable logging for a cleaner testing output +import logging +logging.disable(logging.CRITICAL) + + +@mock.patch('requests.post') +def test_slack_oauth_access_token(mock_post): + """ + API: NotifySlack() OAuth Access Token Tests + + """ + # Disable Throttling to speed testing + plugins.NotifyBase.request_rate_per_sec = 0 + + # Generate an invalid bot token + token = 'xo-invalid' + + request = mock.Mock() + request.content = dumps({ + 'ok': True, + 'message': '', + }) + request.status_code = requests.codes.ok + + # We'll fail to validate the access_token + with pytest.raises(TypeError): + plugins.NotifySlack(access_token=token) + + # Generate a (valid) bot token + token = 'xoxb-1234-1234-abc124' + + # Prepare Mock + mock_post.return_value = request + + # Variation Initializations + obj = plugins.NotifySlack(access_token=token, targets='#apprise') + assert isinstance(obj, plugins.NotifySlack) is True + assert isinstance(obj.url(), six.string_types) is True + + # apprise room was found + assert obj.send(body="test") is True + + # Slack requests pay close attention to the response to determine + # if things go well... this is not a good JSON response: + request.content = '{' + + # As a result, we'll fail to send our notification + assert obj.send(body="test") is False + + request.content = dumps({ + 'ok': False, + 'message': 'We failed', + }) + + # A response from Slack (even with a 200 response) still + # results in a failure: + assert obj.send(body="test") is False + + # Handle exceptions reading our attachment from disk (should it happen) + mock_post.side_effect = OSError("Attachment Error") + mock_post.return_value = None + + # We'll fail now because of an internal exception + assert obj.send(body="test") is False + + +@mock.patch('requests.post') +def test_slack_webhook(mock_post): + """ + API: NotifySlack() Webhook Tests + + """ + # Disable Throttling to speed testing + plugins.NotifyBase.request_rate_per_sec = 0 + + # 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, + }) + + # Initialize some generic (but valid) tokens + token_a = 'A' * 9 + token_b = 'B' * 9 + token_c = 'c' * 24 + + # Support strings + channels = 'chan1,#chan2,+BAK4K23G5,@user,,,' + + obj = plugins.NotifySlack( + token_a=token_a, token_b=token_b, token_c=token_c, targets=channels) + assert len(obj.channels) == 4 + + # This call includes an image with it's payload: + assert obj.notify( + body='body', title='title', notify_type=NotifyType.INFO) is True + + # Missing first Token + with pytest.raises(TypeError): + plugins.NotifySlack( + token_a=None, token_b=token_b, token_c=token_c, + targets=channels) + + # Test include_image + obj = plugins.NotifySlack( + token_a=token_a, token_b=token_b, token_c=token_c, targets=channels, + include_image=True) + + # This call includes an image with it's payload: + assert obj.notify( + body='body', title='title', notify_type=NotifyType.INFO) is True diff --git a/test/var/apprise-test.jpeg b/test/var/apprise-test.jpeg new file mode 100644 index 0000000000..dfb5355d2a Binary files /dev/null and b/test/var/apprise-test.jpeg differ diff --git a/test/var/apprise-test.png b/test/var/apprise-test.png new file mode 100644 index 0000000000..6703efe851 Binary files /dev/null and b/test/var/apprise-test.png differ