diff options
author | morpheus65535 <[email protected]> | 2021-12-01 21:19:18 -0500 |
---|---|---|
committer | morpheus65535 <[email protected]> | 2021-12-01 21:19:18 -0500 |
commit | d51dc68ebb3910ca09bb40c33814d43b93d916b8 (patch) | |
tree | 569807abb70b41eb98dded56c17bd504e793da5b /libs/apprise/plugins/NotifySlack.py | |
parent | 402c82d84f7bd51353348bea7d1a876ad9ecc5b1 (diff) | |
download | bazarr-d51dc68ebb3910ca09bb40c33814d43b93d916b8.tar.gz bazarr-d51dc68ebb3910ca09bb40c33814d43b93d916b8.zip |
Updated Apprise notification module to the latest providers.v1.0.2-beta.2
Diffstat (limited to 'libs/apprise/plugins/NotifySlack.py')
-rw-r--r-- | libs/apprise/plugins/NotifySlack.py | 496 |
1 files changed, 395 insertions, 101 deletions
diff --git a/libs/apprise/plugins/NotifySlack.py b/libs/apprise/plugins/NotifySlack.py index 3e024a64c..ff7907a35 100644 --- a/libs/apprise/plugins/NotifySlack.py +++ b/libs/apprise/plugins/NotifySlack.py @@ -43,7 +43,7 @@ # to add a 'Bot User'. Give it a name and choose 'Add Bot User'. # 4. Now you can choose 'Install App' to which you can choose 'Install App # to Workspace'. -# 5. You will need to authorize the app which you get promopted to do. +# 5. You will need to authorize the app which you get prompted to do. # 6. Finally you'll get some important information providing you your # 'OAuth Access Token' and 'Bot User OAuth Access Token' such as: # slack://{Oauth Access Token} @@ -53,6 +53,21 @@ # ... or: # slack://xoxb-1234-1234-4ddbc191d40ee098cbaae6f3523ada2d # +# You must at least give your bot the following access for it to +# be useful: +# - chat:write - MUST be set otherwise you can not post into +# a channel +# - users:read.email - Required if you want to be able to lookup +# users by their email address. +# +# The easiest way to bring a bot into a channel (so that it can send +# a message to it is to invite it. At this time Apprise does not support +# an auto-join functionality. To do this: +# - In the 'Details' section of your channel +# - Click on the 'More' [...] (elipse icon) +# - Click 'Add apps' +# - You will be able to select the Bot App you previously created +# - Your bot will join your channel. import re import requests @@ -64,6 +79,7 @@ from .NotifyBase import NotifyBase from ..common import NotifyImageSize from ..common import NotifyType from ..common import NotifyFormat +from ..utils import is_email from ..utils import parse_bool from ..utils import parse_list from ..utils import validate_regex @@ -202,6 +218,11 @@ class NotifySlack(NotifyBase): 'prefix': '+', 'map_to': 'targets', }, + 'target_email': { + 'name': _('Target Email'), + 'type': 'string', + 'map_to': 'targets', + }, 'target_user': { 'name': _('Target User'), 'type': 'string', @@ -234,14 +255,26 @@ class NotifySlack(NotifyBase): 'default': True, 'map_to': 'include_footer', }, + # Use Payload in Blocks (vs legacy way): + # See: https://api.slack.com/reference/messaging/payload + 'blocks': { + 'name': _('Use Blocks'), + 'type': 'bool', + 'default': False, + 'map_to': 'use_blocks', + }, '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, token_c=None, targets=None, include_image=True, - include_footer=True, **kwargs): + include_footer=True, use_blocks=None, **kwargs): """ Initialize Slack Object """ @@ -287,6 +320,16 @@ 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 = {} + + self.use_blocks = parse_bool( + use_blocks, self.template_args['blocks']['default']) \ + if use_blocks is not None \ + else self.template_args['blocks']['default'] + # Build list of channels self.channels = parse_list(targets) if len(self.channels) == 0: @@ -330,43 +373,109 @@ class NotifySlack(NotifyBase): # error tracking (used for function return) has_error = False - # Perform Formatting - title = self._re_formatting_rules.sub( # pragma: no branch - lambda x: self._re_formatting_map[x.group()], title, - ) - body = self._re_formatting_rules.sub( # pragma: no branch - lambda x: self._re_formatting_map[x.group()], body, - ) - + # # Prepare JSON Object (applicable to both WEBHOOK and BOT mode) - payload = { - 'username': self.user if self.user else self.app_id, - # Use Markdown language - 'mrkdwn': (self.notify_format == NotifyFormat.MARKDOWN), - 'attachments': [{ - 'title': title, - 'text': body, - 'color': self.color(notify_type), - # Time - 'ts': time(), - }], - } + # + if self.use_blocks: + # Our slack format + _slack_format = 'mrkdwn' \ + if self.notify_format == NotifyFormat.MARKDOWN \ + else 'plain_text' + + payload = { + 'username': self.user if self.user else self.app_id, + 'attachments': [{ + 'blocks': [{ + 'type': 'section', + 'text': { + 'type': _slack_format, + 'text': body + } + }], + 'color': self.color(notify_type), + }] + } + + # Slack only accepts non-empty header sections + if title: + payload['attachments'][0]['blocks'].insert(0, { + 'type': 'header', + 'text': { + 'type': 'plain_text', + 'text': title, + 'emoji': True + } + }) - # 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, + # Include the footer only if specified to do so + if self.include_footer: + + # 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) + + # Prepare our footer based on the block structure + _footer = { + 'type': 'context', + 'elements': [{ + 'type': _slack_format, + 'text': self.app_id + }] + } + + if image_url: + payload['icon_url'] = image_url + + _footer['elements'].insert(0, { + 'type': 'image', + 'image_url': image_url, + 'alt_text': notify_type + }) + + payload['attachments'][0]['blocks'].append(_footer) + + else: + # + # Legacy API Formatting + # + if self.notify_format == NotifyFormat.MARKDOWN: + body = self._re_formatting_rules.sub( # pragma: no branch + lambda x: self._re_formatting_map[x.group()], body, + ) + + # Perform Formatting on title here; this is not needed for block + # mode above + title = self._re_formatting_rules.sub( # pragma: no branch + lambda x: self._re_formatting_map[x.group()], title, ) - else: # SlackMode.BOT - url = self.api_url.format('chat.postMessage') + # Prepare JSON Object (applicable to both WEBHOOK and BOT mode) + payload = { + 'username': self.user if self.user else self.app_id, + # Use Markdown language + 'mrkdwn': (self.notify_format == NotifyFormat.MARKDOWN), + 'attachments': [{ + 'title': title, + 'text': body, + 'color': self.color(notify_type), + # Time + 'ts': time(), + }], + } + # 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['icon_url'] = image_url - if self.include_footer: # Include the footer only if specified to do so - payload['attachments'][0]['footer'] = self.app_id + if self.include_footer: + if image_url: + payload['attachments'][0]['footer_icon'] = image_url + + # 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 @@ -374,6 +483,18 @@ class NotifySlack(NotifyBase): self.logger.warning( 'Slack Webhooks do not support attachments.') + # Prepare our Slack 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') + # Create a copy of the channel list channels = list(self.channels) @@ -382,45 +503,47 @@ 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. 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['icon_url'] = image_url - - if self.include_footer: - payload['attachments'][0]['footer_icon'] = image_url - response = self._send(url, payload) if not response: # Handle any error @@ -465,6 +588,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': '[email protected]', + # '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 +756,7 @@ class NotifySlack(NotifyBase): headers = { 'User-Agent': self.app_id, + 'Accept': 'application/json', } if not attach: @@ -486,7 +766,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 +788,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 +827,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": [ @@ -653,19 +930,20 @@ class NotifySlack(NotifyBase): params = { 'image': 'yes' if self.include_image else 'no', 'footer': 'yes' if self.include_footer else 'no', + 'blocks': 'yes' if self.use_blocks else 'no', } # 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 +957,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,24 +993,35 @@ class NotifySlack(NotifyBase): else: # We're dealing with a webhook results['token_a'] = token + results['token_b'] = entries.pop(0) if entries else None + results['token_c'] = entries.pop(0) if entries else None - # Now fetch the remaining tokens - try: - results['token_b'] = entries.pop(0) - - except IndexError: - # We're done - results['token_b'] = None + # assign remaining entries to the channels we wish to notify + results['targets'] = entries - try: - results['token_c'] = entries.pop(0) + # 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'])))] - except IndexError: - # We're done + # 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 - # assign remaining entries to the channels we wish to notify - results['targets'] = entries + 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 @@ -744,6 +1034,10 @@ class NotifySlack(NotifyBase): results['include_image'] = \ parse_bool(results['qsd'].get('image', True)) + # Get Payload structure (use blocks?) + if 'blocks' in results['qsd'] and len(results['qsd']['blocks']): + results['use_blocks'] = parse_bool(results['qsd']['blocks']) + # Get Footer Flag results['include_footer'] = \ parse_bool(results['qsd'].get('footer', True)) |