Slack compatiblity with its new 2024 API

pull/1295/head
Chris Caron 2025-02-22 09:11:31 -05:00
parent 9bf45e415d
commit a3155a6062
1 changed files with 264 additions and 110 deletions

View File

@ -27,7 +27,13 @@
# POSSIBILITY OF SUCH DAMAGE. # POSSIBILITY OF SUCH DAMAGE.
# There are 2 ways to use this plugin... # 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/ # Visit https://my.slack.com/services/new/incoming-webhook/
# to create a new incoming webhook for your account. You'll need to # 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 # follow the wizard to pre-determine the channel(s) you want your
@ -38,7 +44,7 @@
# | | | # | | |
# These are important <--------------^---------^---------------^ # These are important <--------------^---------^---------------^
# #
# Method 2: Via a Bot: # Method 2 (legacy) : Via a Bot:
# 1. visit: https://api.slack.com/apps?new_app=1 # 1. visit: https://api.slack.com/apps?new_app=1
# 2. Pick an App Name (such as Apprise) and select your workspace. Then # 2. Pick an App Name (such as Apprise) and select your workspace. Then
# press 'Create App' # press 'Create App'
@ -77,7 +83,7 @@ import requests
from json import dumps from json import dumps
from json import loads from json import loads
from time import time from time import time
from datetime import (datetime, timezone)
from .base import NotifyBase from .base import NotifyBase
from ..common import NotifyImageSize from ..common import NotifyImageSize
from ..common import NotifyType from ..common import NotifyType
@ -98,6 +104,12 @@ CHANNEL_LIST_DELIM = re.compile(r'[ \t\r\n,#\\/]+')
CHANNEL_RE = re.compile( CHANNEL_RE = re.compile(
r'^(?P<channel>[+#@]?[A-Z0-9_-]{1,32})(:(?P<thread_ts>[0-9.]+))?$', re.I) r'^(?P<channel>[+#@]?[A-Z0-9_-]{1,32})(:(?P<thread_ts>[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: 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): class NotifySlack(NotifyBase):
""" """
A wrapper for Slack Notifications A wrapper for Slack Notifications
@ -164,13 +207,28 @@ class NotifySlack(NotifyBase):
# becomes the default channel in BOT mode # becomes the default channel in BOT mode
default_notification_channel = '#general' 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 # Define object templates
templates = ( templates = (
# Webhook # Webhook (2024+)
'{schema}://{token_a}/{token_b}/{token_c}', '{schema}://{client_id}/{secret}/', # code-aquisition URL
'{schema}://{botname}@{token_a}/{token_b}{token_c}', '{schema}://{client_id}/{secret}/{code}',
'{schema}://{token_a}/{token_b}/{token_c}/{targets}', '{schema}://{client_id}/{secret}/{code}/{targets}',
'{schema}://{botname}@{token_a}/{token_b}/{token_c}/{targets}',
# Webhook (legacy)
'{schema}://{token}',
'{schema}://{botname}@{token}',
'{schema}://{token}/{targets}',
'{schema}://{botname}@{token}/{targets}',
# Bot # Bot
'{schema}://{access_token}/', '{schema}://{access_token}/',
@ -179,6 +237,24 @@ class NotifySlack(NotifyBase):
# Define our template tokens # Define our template tokens
template_tokens = dict(NotifyBase.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': { 'botname': {
'name': _('Bot Name'), 'name': _('Bot Name'),
'type': 'string', 'type': 'string',
@ -191,32 +267,15 @@ class NotifySlack(NotifyBase):
'name': _('OAuth Access Token'), 'name': _('OAuth Access Token'),
'type': 'string', 'type': 'string',
'private': True, 'private': True,
'required': True,
'regex': (r'^xox[abp]-[A-Z0-9-]+$', 'i'), 'regex': (r'^xox[abp]-[A-Z0-9-]+$', 'i'),
}, },
# Token required as part of the Webhook request # Token required as part of the Webhook request
# /AAAAAAAAA/........./........................ # AAAAAAAAA/BBBBBBBBB/CCCCCCCCCCCCCCCCCCCCCCCC
'token_a': { 'token': {
'name': _('Token A'), 'name': _('Legacy Webhook Token'),
'type': 'string', 'type': 'string',
'private': True, 'private': True,
'regex': (r'^[A-Z0-9]+$', 'i'), 'regex': (r'^[a-z0-9]+/[a-z0-9]+/[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'),
}, },
'target_encoded_id': { 'target_encoded_id': {
'name': _('Target Encoded ID'), 'name': _('Target Encoded ID'),
@ -272,9 +331,24 @@ class NotifySlack(NotifyBase):
'to': { 'to': {
'alias_of': 'targets', 'alias_of': 'targets',
}, },
'client_id': {
'alias_of': 'client_id',
},
'secret': {
'alias_of': 'secret',
},
'code': {
'alias_of': 'code',
},
'token': { 'token': {
'name': _('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<val>[^\n]+?)[ \t]*)?(?:>|\&gt;)' r'(?:[ \t]*\|[ \t]*(?:(?P<val>[^\n]+?)[ \t]*)?(?:>|\&gt;)'
r'|(?:>|\&gt;)))', re.IGNORECASE) r'|(?:>|\&gt;)))', re.IGNORECASE)
def __init__(self, access_token=None, token_a=None, token_b=None, def __init__(self, access_token=None, token=None, targets=None,
token_c=None, targets=None, include_image=True, include_image=None, include_footer=None, use_blocks=None,
include_footer=True, use_blocks=None, **kwargs): ver=None,
# Entries needed for Webhook - Slack API v2 (2024+)
client_id=None, secret=None, code=None,
# Catch-all
**kwargs):
""" """
Initialize Slack Object Initialize Slack Object
""" """
@ -323,39 +403,38 @@ class NotifySlack(NotifyBase):
# Setup our mode # Setup our mode
self.mode = SlackMode.BOT if access_token else SlackMode.WEBHOOK 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: if self.mode is SlackMode.WEBHOOK:
self.access_token = None self.access_token = None
self.token_a = validate_regex( self.token = validate_regex(
token_a, *self.template_tokens['token_a']['regex']) token, *self.template_tokens['token']['regex'])
if not self.token_a: if not self.token:
msg = 'An invalid Slack (first) Token ' \ msg = 'An invalid Slack Token ' \
'({}) was specified.'.format(token_a) '({}) was specified.'.format(token)
self.logger.warning(msg) self.logger.warning(msg)
raise TypeError(msg) raise TypeError(msg)
self.token_b = validate_regex( else: # Bot
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
self.access_token = validate_regex( self.access_token = validate_regex(
access_token, *self.template_tokens['access_token']['regex']) access_token, *self.template_tokens['access_token']['regex'])
if not self.access_token: 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) '({}) was specified.'.format(access_token)
self.logger.warning(msg) self.logger.warning(msg)
raise TypeError(msg) raise TypeError(msg)
@ -386,12 +465,52 @@ class NotifySlack(NotifyBase):
re.IGNORECASE, re.IGNORECASE,
) )
# Place a thumbnail image inline with the message body # 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 # 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 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, def send(self, body, title='', notify_type=NotifyType.INFO, attach=None,
**kwargs): **kwargs):
""" """
@ -557,12 +676,7 @@ class NotifySlack(NotifyBase):
# Prepare our Slack URL (depends on mode) # Prepare our Slack URL (depends on mode)
if self.mode is SlackMode.WEBHOOK: if self.mode is SlackMode.WEBHOOK:
url = '{}/{}/{}/{}'.format( url = '{}/{}'.format(self.webhook_url, self.token)
self.webhook_url,
self.token_a,
self.token_b,
self.token_c,
)
else: # SlackMode.BOT else: # SlackMode.BOT
url = self.api_url.format('chat.postMessage') url = self.api_url.format('chat.postMessage')
@ -1029,8 +1143,8 @@ class NotifySlack(NotifyBase):
here. here.
""" """
return ( return (
self.secure_protocol, self.token_a, self.token_b, self.token_c, self.secure_protocol, self.token, self.access_token,
self.access_token, self.client_id, self.secret, self.code
) )
def url(self, privacy=False, *args, **kwargs): def url(self, privacy=False, *args, **kwargs):
@ -1043,6 +1157,7 @@ class NotifySlack(NotifyBase):
'image': 'yes' if self.include_image else 'no', 'image': 'yes' if self.include_image else 'no',
'footer': 'yes' if self.include_footer else 'no', 'footer': 'yes' if self.include_footer else 'no',
'blocks': 'yes' if self.use_blocks else 'no', 'blocks': 'yes' if self.use_blocks else 'no',
'ver': SLACK_API_VERSIONS[self.api_ver],
} }
# Extend our parameters # Extend our parameters
@ -1056,18 +1171,33 @@ class NotifySlack(NotifyBase):
) )
if self.mode == SlackMode.WEBHOOK: if self.mode == SlackMode.WEBHOOK:
return '{schema}://{botname}{token_a}/{token_b}/{token_c}/'\
if self.api_ver == SlackAPIVersion.ONE:
return '{schema}://{botname}{token}/'\
'{targets}/?{params}'.format( '{targets}/?{params}'.format(
schema=self.secure_protocol, schema=self.secure_protocol,
botname=botname, botname=botname,
token_a=self.pprint(self.token_a, privacy, safe=''), token=self.pprint(self.token, privacy, safe=''),
token_b=self.pprint(self.token_b, privacy, safe=''),
token_c=self.pprint(self.token_c, privacy, safe=''),
targets='/'.join( targets='/'.join(
[NotifySlack.quote(x, safe='') [NotifySlack.quote(x, safe='')
for x in self.channels]), for x in self.channels]),
params=NotifySlack.urlencode(params), params=NotifySlack.urlencode(params),
) )
return '{schema}://{botname}{client_id}/{secret}{code}'\
'{targets}?{params}'.format(
schema=self.secure_protocol,
botname=botname,
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])) if self.channels else '',
params=NotifySlack.urlencode(params),
)
# else -> self.mode == SlackMode.BOT: # else -> self.mode == SlackMode.BOT:
return '{schema}://{botname}{access_token}/{targets}/'\ return '{schema}://{botname}{access_token}/{targets}/'\
'?{params}'.format( '?{params}'.format(
@ -1098,48 +1228,70 @@ class NotifySlack(NotifyBase):
return results return results
# The first token is stored in the hostname # 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 # 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: # Support Slack API Version
if 'ver' in results['qsd'] and len(results['qsd']['ver']):
results['ver'] = results['qsd']['ver']
# 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']
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']
if 'token' in results['qsd'] and len(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 token.startswith('xo'): if token.startswith('xo'):
# We're dealing with a bot # We're dealing with a bot
results['access_token'] = token results['access_token'] = token
results['token'] = None
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
# 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 else: # Webhook
results['access_token'] = None results['access_token'] = None
results['token_a'] = entries.pop(0) if entries else None results['token'] = token
results['token_b'] = entries.pop(0) if entries else None
results['token_c'] = entries.pop(0) if entries else None # 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 # Support the 'to' variable so that we can support rooms this way too
# The 'to' makes it easier to use yaml configuration # The 'to' makes it easier to use yaml configuration
@ -1150,7 +1302,8 @@ class NotifySlack(NotifyBase):
# Get Image Flag # Get Image Flag
results['include_image'] = \ 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?) # Get Payload structure (use blocks?)
if 'blocks' in results['qsd'] and len(results['qsd']['blocks']): if 'blocks' in results['qsd'] and len(results['qsd']['blocks']):
@ -1158,14 +1311,15 @@ class NotifySlack(NotifyBase):
# Get Footer Flag # Get Footer Flag
results['include_footer'] = \ results['include_footer'] = \
parse_bool(results['qsd'].get('footer', True)) parse_bool(results['qsd'].get(
'footer', NotifySlack.template_args['footer']['default']))
return results return results
@staticmethod @staticmethod
def parse_native_url(url): 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( result = re.match(
@ -1182,7 +1336,7 @@ class NotifySlack(NotifyBase):
token_a=result.group('token_a'), token_a=result.group('token_a'),
token_b=result.group('token_b'), token_b=result.group('token_b'),
token_c=result.group('token_c'), token_c=result.group('token_c'),
params='' if not result.group('params') params='?ver=1' if not result.group('params')
else result.group('params'))) else result.group('params') + '&ver=1'))
return None return None