From a3155a606264c8007b4b0226621a93ada69de166 Mon Sep 17 00:00:00 2001 From: Chris Caron Date: Sat, 22 Feb 2025 09:11:31 -0500 Subject: [PATCH] Slack compatiblity with its new 2024 API --- apprise/plugins/slack.py | 374 +++++++++++++++++++++++++++------------ 1 file changed, 264 insertions(+), 110 deletions(-) diff --git a/apprise/plugins/slack.py b/apprise/plugins/slack.py index 9fa6092e..8c1c97fd 100644 --- a/apprise/plugins/slack.py +++ b/apprise/plugins/slack.py @@ -27,7 +27,13 @@ # POSSIBILITY OF SUCH DAMAGE. # There are 2 ways to use this plugin... -# Method 1: Via Webhook: +# Method 1 : Via Webhook: +# Visit https://api.slack.com/apps +# - Click on 'Create new App' +# - Create one from Scratch +# - Provide it an 'App Name' and 'Workspace' + +# Method 1 (legacy) : 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 @@ -38,7 +44,7 @@ # | | | # These are important <--------------^---------^---------------^ # -# Method 2: Via a Bot: +# Method 2 (legacy) : 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' @@ -77,7 +83,7 @@ import requests from json import dumps from json import loads from time import time - +from datetime import (datetime, timezone) from .base import NotifyBase from ..common import NotifyImageSize from ..common import NotifyType @@ -98,6 +104,12 @@ CHANNEL_LIST_DELIM = re.compile(r'[ \t\r\n,#\\/]+') CHANNEL_RE = re.compile( r'^(?P[+#@]?[A-Z0-9_-]{1,32})(:(?P[0-9.]+))?$', re.I) +# For detecting Slack API v2 Client IDs +CLIENT_ID_RE = re.compile(r'^\d{8,}\.\d{8,}$', re.I) + +# For detecting Slack API v2 Codes +CODE_RE = re.compile(r'^[a-z0-9_-]{10,}$', re.I) + class SlackMode: """ @@ -120,6 +132,37 @@ SLACK_MODES = ( ) +class SlackAPIVersion: + """ + Slack API Version + """ + # Original - Said to be depricated on March 31st, 2025 + ONE = '1' + + # New 2024 API Format + TWO = '2' + + +SLACK_API_VERSION_MAP = { + # v1 + "v1": SlackAPIVersion.ONE, + "1": SlackAPIVersion.ONE, + # v2 + "v2": SlackAPIVersion.TWO, + "2": SlackAPIVersion.TWO, + "2024": SlackAPIVersion.TWO, + "2025": SlackAPIVersion.TWO, + "default": SlackAPIVersion.ONE, +} + + +SLACK_API_VERSIONS = { + # Note: This also acts as a reverse lookup mapping + SlackAPIVersion.ONE: 'v1', + SlackAPIVersion.TWO: 'v2', +} + + class NotifySlack(NotifyBase): """ A wrapper for Slack Notifications @@ -164,13 +207,28 @@ class NotifySlack(NotifyBase): # becomes the default channel in BOT mode default_notification_channel = '#general' + # The scopes required to work with Slack + slack_v2_oauth_scopes = ( + # Required for creating a message + 'chat:write', + # Required for attachments + 'files:write', + # Required for looking up a user id when provided ones email + 'users:read.email' + ) + # 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}', + # Webhook (2024+) + '{schema}://{client_id}/{secret}/', # code-aquisition URL + '{schema}://{client_id}/{secret}/{code}', + '{schema}://{client_id}/{secret}/{code}/{targets}', + + # Webhook (legacy) + '{schema}://{token}', + '{schema}://{botname}@{token}', + '{schema}://{token}/{targets}', + '{schema}://{botname}@{token}/{targets}', # Bot '{schema}://{access_token}/', @@ -179,6 +237,24 @@ class NotifySlack(NotifyBase): # Define our template tokens template_tokens = dict(NotifyBase.template_tokens, **{ + # Slack API v2 (2024+) + 'client_id': { + 'name': _('Client ID'), + 'type': 'string', + 'private': True, + }, + 'secret': { + 'name': _('Client Secret'), + 'type': 'string', + 'private': True, + }, + 'code': { + 'name': _('Access Code'), + 'type': 'string', + 'private': True, + }, + + # Legacy Slack API v1 'botname': { 'name': _('Bot Name'), 'type': 'string', @@ -191,32 +267,15 @@ class NotifySlack(NotifyBase): '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'), + # AAAAAAAAA/BBBBBBBBB/CCCCCCCCCCCCCCCCCCCCCCCC + 'token': { + 'name': _('Legacy Webhook Token'), 'type': 'string', 'private': True, - 'regex': (r'^[A-Z0-9]+$', 'i'), - }, - # Token required as part of the Webhook request - # /........./BBBBBBBBB/........................ - 'token_b': { - 'name': _('Token B'), - 'type': 'string', - 'private': True, - 'regex': (r'^[A-Z0-9]+$', 'i'), - }, - # Token required as part of the Webhook request - # /........./........./CCCCCCCCCCCCCCCCCCCCCCCC - 'token_c': { - 'name': _('Token C'), - 'type': 'string', - 'private': True, - 'regex': (r'^[A-Za-z0-9]+$', 'i'), + 'regex': (r'^[a-z0-9]+/[a-z0-9]+/[a-z0-9]$', 'i'), }, 'target_encoded_id': { 'name': _('Target Encoded ID'), @@ -272,9 +331,24 @@ class NotifySlack(NotifyBase): 'to': { 'alias_of': 'targets', }, + 'client_id': { + 'alias_of': 'client_id', + }, + 'secret': { + 'alias_of': 'secret', + }, + 'code': { + 'alias_of': 'code', + }, 'token': { 'name': _('Token'), - 'alias_of': ('access_token', 'token_a', 'token_b', 'token_c'), + 'alias_of': ('access_token', 'token'), + }, + 'ver': { + 'name': _('Slack API Version'), + 'type': 'choice:string', + 'values': ('v1', 'v2'), + 'default': 'v1', }, }) @@ -312,9 +386,15 @@ class NotifySlack(NotifyBase): r'(?:[ \t]*\|[ \t]*(?:(?P[^\n]+?)[ \t]*)?(?:>|\>)' r'|(?:>|\>)))', re.IGNORECASE) - def __init__(self, access_token=None, token_a=None, token_b=None, - token_c=None, targets=None, include_image=True, - include_footer=True, use_blocks=None, **kwargs): + def __init__(self, access_token=None, token=None, targets=None, + include_image=None, include_footer=None, use_blocks=None, + ver=None, + + # Entries needed for Webhook - Slack API v2 (2024+) + client_id=None, secret=None, code=None, + + # Catch-all + **kwargs): """ Initialize Slack Object """ @@ -323,39 +403,38 @@ class NotifySlack(NotifyBase): # Setup our mode self.mode = SlackMode.BOT if access_token else SlackMode.WEBHOOK + # Get our Slack API Version + self.api_ver = \ + SLACK_API_VERSION_MAP[NotifySlack. + template_args['ver']['default']] \ + if ver is None else \ + next(( + v for k, v in SLACK_API_VERSION_MAP.items() + if str(ver).lower().startswith(k)), + SLACK_API_VERSION_MAP[NotifySlack. + template_args['ver']['default']]) + + # Depricated Notification + if self.api_ver == SlackAPIVersion.ONE: + self.logger.deprecate( + 'Slack Legacy API is set to be deprecated on Mar 31st, 2025. ' + 'You must update your App and/or Bot') + if self.mode is SlackMode.WEBHOOK: self.access_token = None - 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.token = validate_regex( + token, *self.template_tokens['token']['regex']) + if not self.token: + msg = 'An invalid Slack Token ' \ + '({}) was specified.'.format(token) 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.token_a = None - self.token_b = None - self.token_c = None + else: # Bot 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 ' \ + msg = 'An invalid Slack (Bot) OAuth Access Token ' \ '({}) was specified.'.format(access_token) self.logger.warning(msg) raise TypeError(msg) @@ -386,12 +465,52 @@ class NotifySlack(NotifyBase): re.IGNORECASE, ) # Place a thumbnail image inline with the message body - self.include_image = include_image + self.include_image = include_image if include_image is not None \ + else self.template_args['image']['default'] # Place a footer with each post - self.include_footer = include_footer + self.include_footer = include_footer if include_footer is not None \ + else self.template_args['footer']['default'] + + # Access token is required with the new 2024 Slack API and + # is acquired after authenticating + self.__refresh_token = None + self.__access_token = None + self.__access_token_expiry = datetime.now(timezone.utc) return + def authenticate(self, **kwargs): + """ + Authenticates with Slack API Servers + """ + + # First we need to acquire a code + params = { + 'client_id': self.client_id, + 'scope': ','.join(self.slack_v2_oauth_scopes), + # Out of Band + 'redirect_uri': 'urn:ietf:wg:oauth:2.0:oob', + } + + get_code_url = 'https://slack.com/oauth/v2/authorize' + try: + r = requests.post( + get_code_url, + params=params, + verify=self.verify_certificate, + timeout=self.request_timeout, + ) + import pdb + pdb.set_trace() + + except requests.RequestException as e: + self.logger.warning( + 'A Connection error occurred acquiring Slack access code.', + ) + self.logger.debug('Socket Exception: %s' % str(e)) + # Return; we're done + return None + def send(self, body, title='', notify_type=NotifyType.INFO, attach=None, **kwargs): """ @@ -557,12 +676,7 @@ class NotifySlack(NotifyBase): # 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, - ) + url = '{}/{}'.format(self.webhook_url, self.token) else: # SlackMode.BOT url = self.api_url.format('chat.postMessage') @@ -1029,8 +1143,8 @@ class NotifySlack(NotifyBase): here. """ return ( - self.secure_protocol, self.token_a, self.token_b, self.token_c, - self.access_token, + self.secure_protocol, self.token, self.access_token, + self.client_id, self.secret, self.code ) def url(self, privacy=False, *args, **kwargs): @@ -1043,6 +1157,7 @@ class NotifySlack(NotifyBase): 'image': 'yes' if self.include_image else 'no', 'footer': 'yes' if self.include_footer else 'no', 'blocks': 'yes' if self.use_blocks else 'no', + 'ver': SLACK_API_VERSIONS[self.api_ver], } # Extend our parameters @@ -1056,18 +1171,33 @@ class NotifySlack(NotifyBase): ) if self.mode == SlackMode.WEBHOOK: - return '{schema}://{botname}{token_a}/{token_b}/{token_c}/'\ - '{targets}/?{params}'.format( + + if self.api_ver == SlackAPIVersion.ONE: + return '{schema}://{botname}{token}/'\ + '{targets}/?{params}'.format( + schema=self.secure_protocol, + botname=botname, + token=self.pprint(self.token, privacy, safe=''), + targets='/'.join( + [NotifySlack.quote(x, safe='') + for x in self.channels]), + params=NotifySlack.urlencode(params), + ) + + return '{schema}://{botname}{client_id}/{secret}{code}'\ + '{targets}?{params}'.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( + client_id=self.pprint(self.client_id, privacy, safe=''), + secret=self.pprint(self.secret, privacy, safe=''), + code='' if not self.code else '/' + self.pprint( + self.code, privacy, safe=''), + targets=('/' + '/'.join( [NotifySlack.quote(x, safe='') - for x in self.channels]), + for x in self.channels])) if self.channels else '', params=NotifySlack.urlencode(params), ) + # else -> self.mode == SlackMode.BOT: return '{schema}://{botname}{access_token}/{targets}/'\ '?{params}'.format( @@ -1098,48 +1228,70 @@ class NotifySlack(NotifyBase): return results # The first token is stored in the hostname - token = NotifySlack.unquote(results['host']) + results['targets'] = \ + [NotifySlack.unquote(results['host'])] if results['host'] else [] # Get unquoted entries - entries = NotifySlack.split_path(results['fullpath']) + results['targets'] += NotifySlack.split_path(results['fullpath']) - # 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 + # Support Slack API Version + if 'ver' in results['qsd'] and len(results['qsd']['ver']): + results['ver'] = results['qsd']['ver'] - 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 + # Get our values if defined + if 'client_id' in results['qsd'] and len(results['qsd']['client_id']): + # We're dealing with a Slack v2 API + results['client_id'] = results['qsd']['client_id'] - # assign remaining entries to the channels we wish to notify - results['targets'] = entries + if 'secret' in results['qsd'] and len(results['qsd']['secret']): + # We're dealing with a Slack v2 API + results['secret'] = results['qsd']['secret'] + + if 'code' in results['qsd'] and len(results['qsd']['code']): + # We're dealing with a Slack v2 API + results['code'] = results['qsd']['code'] - # 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'])))] - + # We're dealing with a Slack v1 API + token = NotifySlack.unquote(results['qsd']['token']) # check to see if we're dealing with a bot/user token - if entries and entries[0].startswith('xo'): + if token.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 + results['access_token'] = token + results['token'] = 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 + results['token'] = token + + # Verify if our token_a us a bot token or part of a webhook: + if not (results.get('token') or results.get('access_token') + or 'client_id' in results or 'secret' in results + or 'code' in results) and results['targets'] \ + and results['targets'][0].startswith('xo'): + + # We're dealing with a bot + results['access_token'] = results['targets'].pop(0) + results['token'] = None + + elif 'client_id' not in results and results['targets'] \ + and CLIENT_ID_RE.match(results['targets'][0]): + # Store our Client ID + results['client_id'] = results['targets'].pop(0) + + # We have several entries on our URL and we don't know where they + # go. They could also be channels/users/emails + if 'client_id' in results and 'secret' not in results: + # Acquire secret + results['secret'] = \ + results['targets'].pop(0) if results['targets'] else None + + if 'secret' in results and 'code' not in results \ + and results['targets'] and \ + CODE_RE.match(results['targets'][0]): + + # Acquire our code + results['code'] = results['targets'].pop(0) # Support the 'to' variable so that we can support rooms this way too # The 'to' makes it easier to use yaml configuration @@ -1150,7 +1302,8 @@ class NotifySlack(NotifyBase): # Get Image Flag results['include_image'] = \ - parse_bool(results['qsd'].get('image', True)) + parse_bool(results['qsd'].get( + 'image', NotifySlack.template_args['image']['default'])) # Get Payload structure (use blocks?) if 'blocks' in results['qsd'] and len(results['qsd']['blocks']): @@ -1158,14 +1311,15 @@ class NotifySlack(NotifyBase): # Get Footer Flag results['include_footer'] = \ - parse_bool(results['qsd'].get('footer', True)) + parse_bool(results['qsd'].get( + 'footer', NotifySlack.template_args['footer']['default'])) return results @staticmethod def parse_native_url(url): """ - Support https://hooks.slack.com/services/TOKEN_A/TOKEN_B/TOKEN_C + Legacy Support https://hooks.slack.com/services/TOKEN_A/TOKEN_B/TOKEN_C """ result = re.match( @@ -1182,7 +1336,7 @@ class NotifySlack(NotifyBase): token_a=result.group('token_a'), token_b=result.group('token_b'), token_c=result.group('token_c'), - params='' if not result.group('params') - else result.group('params'))) + params='?ver=1' if not result.group('params') + else result.group('params') + '&ver=1')) return None