aboutsummaryrefslogtreecommitdiffhomepage
path: root/libs/apprise/plugins/NotifySlack.py
diff options
context:
space:
mode:
authormorpheus65535 <[email protected]>2021-12-01 21:19:18 -0500
committermorpheus65535 <[email protected]>2021-12-01 21:19:18 -0500
commitd51dc68ebb3910ca09bb40c33814d43b93d916b8 (patch)
tree569807abb70b41eb98dded56c17bd504e793da5b /libs/apprise/plugins/NotifySlack.py
parent402c82d84f7bd51353348bea7d1a876ad9ecc5b1 (diff)
downloadbazarr-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.py496
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))