Fixed MSTeams webhook handling for new format (#380)

This commit is contained in:
Chris Caron 2021-05-02 10:11:51 -04:00 committed by GitHub
parent 0b60e99aa4
commit 975b1721af
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
2 changed files with 145 additions and 43 deletions

View File

@ -43,16 +43,16 @@
# #
# When you've completed this, it will generate you a (webhook) URL that # When you've completed this, it will generate you a (webhook) URL that
# looks like: # looks like:
# https://team-name.office.com/webhook/ \ # https://team-name.webhook.office.com/webhookb2/ \
# abcdefgf8-2f4b-4eca-8f61-225c83db1967@abcdefg2-5a99-4849-8efc-\ # abcdefgf8-2f4b-4eca-8f61-225c83db1967@abcdefg2-5a99-4849-8efc-\
# c9e78d28e57d/IncomingWebhook/291289f63a8abd3593e834af4d79f9fe/\ # c9e78d28e57d/IncomingWebhook/291289f63a8abd3593e834af4d79f9fe/\
# a2329f43-0ffb-46ab-948b-c9abdad9d643 # a2329f43-0ffb-46ab-948b-c9abdad9d643
# #
# Yes... The URL is that big... But it looks like this (greatly simplified): # Yes... The URL is that big... But it looks like this (greatly simplified):
# https://TEAM-NAME.office.com/webhook/ABCD/IncomingWebhook/DEFG/HIJK # https://TEAM-NAME.webhook.office.com/webhookb2/ABCD/IncomingWebhook/DEFG/HIJK
# ^ ^ ^ ^ # ^ ^ ^ ^
# | | | | # | | | |
# These are important <----------------^--------------------^----^ # These are important <--------------------------^--------------------^----^
# #
# The Legacy format didn't have the team name identified and reads 'outlook' # The Legacy format didn't have the team name identified and reads 'outlook'
@ -114,7 +114,11 @@ class NotifyMSTeams(NotifyBase):
setup_url = 'https://github.com/caronc/apprise/wiki/Notify_msteams' setup_url = 'https://github.com/caronc/apprise/wiki/Notify_msteams'
# MSTeams uses the http protocol with JSON requests # MSTeams uses the http protocol with JSON requests
notify_url = 'https://{team}.office.com/webhook/' \ notify_url_v1 = 'https://outlook.office.com/webhook/' \
'{token_a}/IncomingWebhook/{token_b}/{token_c}'
# New MSTeams webhook (as of April 11th, 2021)
notify_url_v2 = 'https://{team}.webhook.office.com/webhookb2/' \
'{token_a}/IncomingWebhook/{token_b}/{token_c}' '{token_a}/IncomingWebhook/{token_b}/{token_c}'
# Allows the user to specify the NotifyImageSize object # Allows the user to specify the NotifyImageSize object
@ -185,6 +189,12 @@ class NotifyMSTeams(NotifyBase):
'default': False, 'default': False,
'map_to': 'include_image', 'map_to': 'include_image',
}, },
'version': {
'name': _('Version'),
'type': 'choice:int',
'values': (1, 2),
'default': 2,
},
'template': { 'template': {
'name': _('Template Path'), 'name': _('Template Path'),
'type': 'string', 'type': 'string',
@ -200,7 +210,7 @@ class NotifyMSTeams(NotifyBase):
}, },
} }
def __init__(self, token_a, token_b, token_c, team=None, def __init__(self, token_a, token_b, token_c, team=None, version=None,
include_image=True, template=None, tokens=None, **kwargs): include_image=True, template=None, tokens=None, **kwargs):
""" """
Initialize Microsoft Teams Object Initialize Microsoft Teams Object
@ -212,6 +222,24 @@ class NotifyMSTeams(NotifyBase):
""" """
super(NotifyMSTeams, self).__init__(**kwargs) super(NotifyMSTeams, self).__init__(**kwargs)
try:
self.version = int(version)
except TypeError:
# None was specified... take on default
self.version = self.template_args['version']['default']
except ValueError:
# invalid content was provided; let this get caught in the next
# validation check for the version
self.version = None
if self.version not in self.template_args['version']['values']:
msg = 'An invalid MSTeams Version ' \
'({}) was specified.'.format(version)
self.logger.warning(msg)
raise TypeError(msg)
self.team = validate_regex(team) self.team = validate_regex(team)
if not self.team: if not self.team:
NotifyBase.logger.deprecate( NotifyBase.logger.deprecate(
@ -373,12 +401,16 @@ class NotifyMSTeams(NotifyBase):
'Content-Type': 'application/json', 'Content-Type': 'application/json',
} }
notify_url = self.notify_url.format( notify_url = self.notify_url_v2.format(
team=self.team, team=self.team,
token_a=self.token_a, token_a=self.token_a,
token_b=self.token_b, token_b=self.token_b,
token_c=self.token_c, token_c=self.token_c,
) ) if self.version > 1 else \
self.notify_url_v1.format(
token_a=self.token_a,
token_b=self.token_b,
token_c=self.token_c)
# Generate our payload if it's possible # Generate our payload if it's possible
payload = self.gen_payload( payload = self.gen_payload(
@ -444,6 +476,9 @@ class NotifyMSTeams(NotifyBase):
'image': 'yes' if self.include_image else 'no', 'image': 'yes' if self.include_image else 'no',
} }
if self.version != self.template_args['version']['default']:
params['version'] = str(self.version)
if self.template: if self.template:
params['template'] = NotifyMSTeams.quote( params['template'] = NotifyMSTeams.quote(
self.template[0].url(), safe='') self.template[0].url(), safe='')
@ -453,15 +488,26 @@ class NotifyMSTeams(NotifyBase):
# Store any template entries if specified # Store any template entries if specified
params.update({':{}'.format(k): v for k, v in self.tokens.items()}) params.update({':{}'.format(k): v for k, v in self.tokens.items()})
return '{schema}://{team}/{token_a}/{token_b}/{token_c}/'\ if self.version > 1:
'?{params}'.format( return '{schema}://{team}/{token_a}/{token_b}/{token_c}/'\
schema=self.secure_protocol, '?{params}'.format(
team=NotifyMSTeams.quote(self.team, safe=''), schema=self.secure_protocol,
token_a=self.pprint(self.token_a, privacy, safe=''), team=NotifyMSTeams.quote(self.team, safe=''),
token_b=self.pprint(self.token_b, privacy, safe=''), token_a=self.pprint(self.token_a, privacy, safe=''),
token_c=self.pprint(self.token_c, privacy, safe=''), token_b=self.pprint(self.token_b, privacy, safe=''),
params=NotifyMSTeams.urlencode(params), token_c=self.pprint(self.token_c, privacy, safe=''),
) params=NotifyMSTeams.urlencode(params),
)
else: # Version 1
return '{schema}://{token_a}/{token_b}/{token_c}/'\
'?{params}'.format(
schema=self.secure_protocol,
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=''),
params=NotifyMSTeams.urlencode(params),
)
@staticmethod @staticmethod
def parse_url(url): def parse_url(url):
@ -470,6 +516,7 @@ class NotifyMSTeams(NotifyBase):
us to re-instantiate this object. us to re-instantiate this object.
""" """
results = NotifyBase.parse_url(url, verify_host=False) results = NotifyBase.parse_url(url, verify_host=False)
if not results: if not results:
# We're done early as we couldn't load the results # We're done early as we couldn't load the results
@ -511,6 +558,15 @@ class NotifyMSTeams(NotifyBase):
results['template'] = \ results['template'] = \
NotifyMSTeams.unquote(results['qsd']['template']) NotifyMSTeams.unquote(results['qsd']['template'])
# Override version if defined
if 'version' in results['qsd'] and results['qsd']['version']:
results['version'] = \
NotifyMSTeams.unquote(results['qsd']['version'])
else:
# Set our version if not otherwise set
results['version'] = 1 if not results.get('team') else 2
# Store our tokens # Store our tokens
results['tokens'] = results['qsd:'] results['tokens'] = results['qsd:']
@ -530,7 +586,8 @@ class NotifyMSTeams(NotifyBase):
# of this is just to detect that were dealing with an msteams url # of this is just to detect that were dealing with an msteams url
# token parsing will occur once we initialize the function # token parsing will occur once we initialize the function
result = re.match( result = re.match(
r'^https?://(?P<team>[^.]+)\.office\.com/webhook/' r'^https?://(?P<team>[^.]+)(?P<v2a>\.webhook)?\.office\.com/'
r'webhook(?P<v2b>b2)?/'
r'(?P<token_a>[A-Z0-9-]+@[A-Z0-9-]+)/' r'(?P<token_a>[A-Z0-9-]+@[A-Z0-9-]+)/'
r'IncomingWebhook/' r'IncomingWebhook/'
r'(?P<token_b>[A-Z0-9]+)/' r'(?P<token_b>[A-Z0-9]+)/'
@ -538,15 +595,28 @@ class NotifyMSTeams(NotifyBase):
r'(?P<params>\?.+)?$', url, re.I) r'(?P<params>\?.+)?$', url, re.I)
if result: if result:
return NotifyMSTeams.parse_url( if result.group('v2a'):
'{schema}://{team}/{token_a}/{token_b}/{token_c}' # Version 2 URL
'/{params}'.format( return NotifyMSTeams.parse_url(
schema=NotifyMSTeams.secure_protocol, '{schema}://{team}/{token_a}/{token_b}/{token_c}'
team=result.group('team'), '/{params}'.format(
token_a=result.group('token_a'), schema=NotifyMSTeams.secure_protocol,
token_b=result.group('token_b'), team=result.group('team'),
token_c=result.group('token_c'), token_a=result.group('token_a'),
params='' if not result.group('params') token_b=result.group('token_b'),
else result.group('params'))) token_c=result.group('token_c'),
params='' if not result.group('params')
else result.group('params')))
else:
# Version 1 URLs
# team is also set to 'outlook' in this case
return NotifyMSTeams.parse_url(
'{schema}://{token_a}/{token_b}/{token_c}'
'/{params}'.format(
schema=NotifyMSTeams.secure_protocol,
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')))
return None return None

View File

@ -2015,62 +2015,94 @@ TEST_URLS = (
# Just 2 tokens provided # Just 2 tokens provided
'instance': TypeError, 'instance': TypeError,
}), }),
('msteams://{}@{}/{}/{}?t1'.format(UUID4, UUID4, 'a' * 32, UUID4), { ('msteams://{}@{}/{}/{}?t1'.format(UUID4, UUID4, 'b' * 32, UUID4), {
# All tokens provided - we're good # All tokens provided - we're good
'instance': plugins.NotifyMSTeams, 'instance': plugins.NotifyMSTeams,
}), }),
# Support native URLs # Support native URLs
('https://outlook.office.com/webhook/{}@{}/IncomingWebhook/{}/{}' ('https://outlook.office.com/webhook/{}@{}/IncomingWebhook/{}/{}'
.format(UUID4, UUID4, 'a' * 32, UUID4), { .format(UUID4, UUID4, 'k' * 32, UUID4), {
# All tokens provided - we're good # All tokens provided - we're good
'instance': plugins.NotifyMSTeams}), 'instance': plugins.NotifyMSTeams,
# Our expected url(privacy=True) startswith() response (v1 format)
'privacy_url': 'msteams://8...2/k...k/8...2/'}),
# Support New Native URLs
('https://myteam.webhook.office.com/webhookb2/{}@{}/IncomingWebhook/{}/{}'
.format(UUID4, UUID4, 'm' * 32, UUID4), {
# All tokens provided - we're good
'instance': plugins.NotifyMSTeams,
# Our expected url(privacy=True) startswith() response (v2 format):
'privacy_url': 'msteams://myteam/8...2/m...m/8...2/'}),
# Legacy URL Formatting # Legacy URL Formatting
('msteams://{}@{}/{}/{}?t2'.format(UUID4, UUID4, 'a' * 32, UUID4), { ('msteams://{}@{}/{}/{}?t2'.format(UUID4, UUID4, 'c' * 32, UUID4), {
# All tokens provided - we're good # All tokens provided - we're good
'instance': plugins.NotifyMSTeams, 'instance': plugins.NotifyMSTeams,
# don't include an image by default # don't include an image by default
'include_image': False, 'include_image': False,
}), }),
# Legacy URL Formatting # Legacy URL Formatting
('msteams://{}@{}/{}/{}?image=No'.format(UUID4, UUID4, 'a' * 32, UUID4), { ('msteams://{}@{}/{}/{}?image=No'.format(UUID4, UUID4, 'd' * 32, UUID4), {
# All tokens provided - we're good no image # All tokens provided - we're good no image
'instance': plugins.NotifyMSTeams, 'instance': plugins.NotifyMSTeams,
# Our expected url(privacy=True) startswith() response: # Our expected url(privacy=True) startswith() response:
'privacy_url': 'msteams://outlook/8...2/a...a/8...2/', 'privacy_url': 'msteams://8...2/d...d/8...2/',
}), }),
# New 2021 URL formatting # New 2021 URL formatting
('msteams://apprise/{}@{}/{}/{}'.format( ('msteams://apprise/{}@{}/{}/{}'.format(
UUID4, UUID4, 'a' * 32, UUID4), { UUID4, UUID4, 'e' * 32, UUID4), {
# All tokens provided - we're good no image # All tokens provided - we're good no image
'instance': plugins.NotifyMSTeams, 'instance': plugins.NotifyMSTeams,
# Our expected url(privacy=True) startswith() response: # Our expected url(privacy=True) startswith() response:
'privacy_url': 'msteams://apprise/8...2/a...a/8...2/', 'privacy_url': 'msteams://apprise/8...2/e...e/8...2/',
}), }),
# New 2021 URL formatting; support team= argument # New 2021 URL formatting; support team= argument
('msteams://{}@{}/{}/{}?team=teamname'.format( ('msteams://{}@{}/{}/{}?team=teamname'.format(
UUID4, UUID4, 'a' * 32, UUID4), { UUID4, UUID4, 'f' * 32, UUID4), {
# All tokens provided - we're good no image # All tokens provided - we're good no image
'instance': plugins.NotifyMSTeams, 'instance': plugins.NotifyMSTeams,
# Our expected url(privacy=True) startswith() response: # Our expected url(privacy=True) startswith() response:
'privacy_url': 'msteams://teamname/8...2/a...a/8...2/', 'privacy_url': 'msteams://teamname/8...2/f...f/8...2/',
}), }),
('msteams://{}@{}/{}/{}?tx'.format(UUID4, UUID4, 'a' * 32, UUID4), { # New 2021 URL formatting (forcing v1)
('msteams://apprise/{}@{}/{}/{}?version=1'.format(
UUID4, UUID4, 'e' * 32, UUID4), {
# All tokens provided - we're good
'instance': plugins.NotifyMSTeams,
# Our expected url(privacy=True) startswith() response:
'privacy_url': 'msteams://8...2/e...e/8...2/',
}),
# Invalid versioning
('msteams://apprise/{}@{}/{}/{}?version=999'.format(
UUID4, UUID4, 'e' * 32, UUID4), {
# invalid version
'instance': TypeError,
}),
('msteams://apprise/{}@{}/{}/{}?version=invalid'.format(
UUID4, UUID4, 'e' * 32, UUID4), {
# invalid version
'instance': TypeError,
}),
('msteams://{}@{}/{}/{}?tx'.format(UUID4, UUID4, 'x' * 32, UUID4), {
'instance': plugins.NotifyMSTeams, 'instance': plugins.NotifyMSTeams,
# force a failure # force a failure
'response': False, 'response': False,
'requests_response_code': requests.codes.internal_server_error, 'requests_response_code': requests.codes.internal_server_error,
}), }),
('msteams://{}@{}/{}/{}?ty'.format(UUID4, UUID4, 'a' * 32, UUID4), { ('msteams://{}@{}/{}/{}?ty'.format(UUID4, UUID4, 'y' * 32, UUID4), {
'instance': plugins.NotifyMSTeams, 'instance': plugins.NotifyMSTeams,
# throw a bizzare code forcing us to fail to look it up # throw a bizzare code forcing us to fail to look it up
'response': False, 'response': False,
'requests_response_code': 999, 'requests_response_code': 999,
}), }),
('msteams://{}@{}/{}/{}?tz'.format(UUID4, UUID4, 'a' * 32, UUID4), { ('msteams://{}@{}/{}/{}?tz'.format(UUID4, UUID4, 'z' * 32, UUID4), {
'instance': plugins.NotifyMSTeams, 'instance': plugins.NotifyMSTeams,
# Throws a series of connection and transfer exceptions when this flag # Throws a series of connection and transfer exceptions when this flag
# is set and tests that we gracfully handle them # is set and tests that we gracfully handle them