Microsoft Power Automate / Workflows Support (#1172)

Co-authored-by: Toby Coleman <toby-coleman@users.noreply.github.com>
This commit is contained in:
Chris Caron 2024-07-25 17:33:25 -04:00 committed by GitHub
parent 9620901afc
commit a0328274f3
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 1088 additions and 7 deletions

View File

@ -64,6 +64,7 @@ PagerDuty
PagerTree
ParsePlatform
PopcornNotify
Power Automate
Prowl
PushBullet
Pushed
@ -110,6 +111,7 @@ Webex
WeCom Bot
WhatsApp
Windows
Workflows
XBMC
XML
Zulip

View File

@ -83,6 +83,7 @@ The table below identifies the services this tool supports and some example serv
| [Mastodon](https://github.com/caronc/apprise/wiki/Notify_mastodon) | mastodon:// or mastodons://| (TCP) 80 or 443 | mastodon://access_key@hostname<br />mastodon://access_key@hostname/@user<br />mastodon://access_key@hostname/@user1/@user2/@userN
| [Matrix](https://github.com/caronc/apprise/wiki/Notify_matrix) | matrix:// or matrixs:// | (TCP) 80 or 443 | matrix://hostname<br />matrix://user@hostname<br />matrixs://user:pass@hostname:port/#room_alias<br />matrixs://user:pass@hostname:port/!room_id<br />matrixs://user:pass@hostname:port/#room_alias/!room_id/#room2<br />matrixs://token@hostname:port/?webhook=matrix<br />matrix://user:token@hostname/?webhook=slack&format=markdown
| [Mattermost](https://github.com/caronc/apprise/wiki/Notify_mattermost) | mmost:// or mmosts:// | (TCP) 8065 | mmost://hostname/authkey<br />mmost://hostname:80/authkey<br />mmost://user@hostname:80/authkey<br />mmost://hostname/authkey?channel=channel<br />mmosts://hostname/authkey<br />mmosts://user@hostname/authkey<br />
| [Microsoft Power Automate / Workflows (MSTeams)](https://github.com/caronc/apprise/wiki/Notify_workflows) | workflows:// | (TCP) 443 | workflows://WorkflowID/Signature/
| [Microsoft Teams](https://github.com/caronc/apprise/wiki/Notify_msteams) | msteams:// | (TCP) 443 | msteams://TokenA/TokenB/TokenC/
| [Misskey](https://github.com/caronc/apprise/wiki/Notify_misskey) | misskey:// or misskeys://| (TCP) 80 or 443 | misskey://access_token@hostname
| [MQTT](https://github.com/caronc/apprise/wiki/Notify_mqtt) | mqtt:// or mqtts:// | (TCP) 1883 or 8883 | mqtt://hostname/topic<br />mqtt://user@hostname/topic<br />mqtts://user:pass@hostname:9883/topic
@ -128,10 +129,10 @@ The table below identifies the services this tool supports and some example serv
| [Telegram](https://github.com/caronc/apprise/wiki/Notify_telegram) | tgram:// | (TCP) 443 | tgram://bottoken/ChatID<br />tgram://bottoken/ChatID1/ChatID2/ChatIDN
| [Twitter](https://github.com/caronc/apprise/wiki/Notify_twitter) | twitter:// | (TCP) 443 | twitter://CKey/CSecret/AKey/ASecret<br/>twitter://user@CKey/CSecret/AKey/ASecret<br/>twitter://CKey/CSecret/AKey/ASecret/User1/User2/User2<br/>twitter://CKey/CSecret/AKey/ASecret?mode=tweet
| [Twist](https://github.com/caronc/apprise/wiki/Notify_twist) | twist:// | (TCP) 443 | twist://pasword:login<br/>twist://password:login/#channel<br/>twist://password:login/#team:channel<br/>twist://password:login/#team:channel1/channel2/#team3:channel
| [XBMC](https://github.com/caronc/apprise/wiki/Notify_xbmc) | xbmc:// or xbmcs:// | (TCP) 8080 or 443 | xbmc://hostname<br />xbmc://user@hostname<br />xbmc://user:password@hostname:port
| [Webex Teams (Cisco)](https://github.com/caronc/apprise/wiki/Notify_wxteams) | wxteams:// | (TCP) 443 | wxteams://Token
| [WeCom Bot](https://github.com/caronc/apprise/wiki/Notify_wecombot) | wecombot:// | (TCP) 443 | wecombot://BotKey
| [WhatsApp](https://github.com/caronc/apprise/wiki/Notify_whatsapp) | whatsapp:// | (TCP) 443 | whatsapp://AccessToken@FromPhoneID/ToPhoneNo<br/> whatsapp://Template:AccessToken@FromPhoneID/ToPhoneNo
| [XBMC](https://github.com/caronc/apprise/wiki/Notify_xbmc) | xbmc:// or xbmcs:// | (TCP) 8080 or 443 | xbmc://hostname<br />xbmc://user@hostname<br />xbmc://user:password@hostname:port
| [Zulip Chat](https://github.com/caronc/apprise/wiki/Notify_zulip) | zulip:// | (TCP) 443 | zulip://botname@Organization/Token<br />zulip://botname@Organization/Token/Stream<br />zulip://botname@Organization/Token/Email
## SMS Notifications

View File

@ -293,7 +293,12 @@ class NotifyMSTeams(NotifyBase):
self.logger.warning(msg)
raise TypeError(msg)
# else: NoneType - this is okay
self.logger.deprecate(
"Microsoft is depricating their MSTeams webhooks on "
"December 31, 2024. It is advised that you switch to "
"Microsoft Power Automate (already supported by Apprise as "
"workflows://. For more information visit: "
"https://github.com/caronc/apprise/wiki/Notify_workflows")
return
def gen_payload(self, body, title='', notify_type=NotifyType.INFO,

View File

@ -0,0 +1,565 @@
# -*- coding: utf-8 -*-
# BSD 2-Clause License
#
# Apprise - Push Notification Library.
# Copyright (c) 2024, Chris Caron <lead2gold@gmail.com>
#
# Redistribution and use in source and binary forms, with or without
# modification, are permitted provided that the following conditions are met:
#
# 1. Redistributions of source code must retain the above copyright notice,
# this list of conditions and the following disclaimer.
#
# 2. Redistributions in binary form must reproduce the above copyright notice,
# this list of conditions and the following disclaimer in the documentation
# and/or other materials provided with the distribution.
#
# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
# POSSIBILITY OF SUCH DAMAGE.
# To use this plugin, you need to create a MS Teams Azure Webhook Workflow:
# https://support.microsoft.com/en-us/office/browse-and-add-workflows-\
# in-microsoft-teams-4998095c-8b72-4b0e-984c-f2ad39e6ba9a
# Your webhook will look somthing like this:
# https://prod-161.westeurope.logic.azure.com:443/\
# workflows/643e69f83c8944438d68119179a10a64/triggers/manual/\
# paths/invoke?api-version=2016-06-01&sp=%2Ftriggers%2Fmanual%2Frun&\
# sv=1.0&sig=KODuebWbDGYFr0z0eu-6Rj8aUKz7108W3wrNJZxFE5A
#
# Yes... The URL is that big... But it looks like this (greatly simplified):
# https://HOST:PORT/workflows/ABCD/triggers/manual/path/...sig=DEFG
# ^ ^ ^ ^
# | | | |
# These are important <---------^------------------------------^
#
#
# Apprise can support this webhook as is (directly passed into it)
# Alternatively it can be shortend to:
# These 3 tokens need to be placed in the URL after the Team
# workflows://HOST:PORT/ABCD/DEFG/
#
import re
import requests
import json
from json.decoder import JSONDecodeError
from .base import NotifyBase
from ..common import NotifyImageSize
from ..common import NotifyType
from ..common import NotifyFormat
from ..utils import parse_bool
from ..utils import validate_regex
from ..utils import apply_template
from ..utils import TemplateType
from ..apprise_attachment import AppriseAttachment
from ..locale import gettext_lazy as _
class NotifyWorkflows(NotifyBase):
"""
A wrapper for Microsoft Workflows (MS Teams) Notifications
"""
# The default descriptive name associated with the Notification
service_name = 'Power Automate / Workflows (for MSTeams)'
# The services URL
service_url = 'https://www.microsoft.com/power-platform/' \
'products/power-automate'
# The default secure protocol
secure_protocol = ('workflow', 'workflows')
# A URL that takes you to the setup/help of the specific protocol
setup_url = 'https://github.com/caronc/apprise/wiki/Notify_workflows'
# Allows the user to specify the NotifyImageSize object
image_size = NotifyImageSize.XY_32
# The maximum allowable characters allowed in the body per message
body_maxlen = 1000
# Default Notification Format
notify_format = NotifyFormat.MARKDOWN
# There is no reason we should exceed 35KB when reading in a JSON file.
# If it is more than this, then it is not accepted
max_workflows_template_size = 35000
# Adaptive Card Version
adaptive_card_version = '1.4'
# Define object templates
templates = (
'{schema}://{host}/{workflow}/{signature}',
'{schema}://{host}:{port}/{workflow}/{signature}',
)
# Define our template tokens
template_tokens = dict(NotifyBase.template_tokens, **{
'host': {
'name': _('Hostname'),
'type': 'string',
'required': True,
},
'port': {
'name': _('Port'),
'type': 'int',
'min': 1,
'max': 65535,
},
# workflow identifier
'workflow': {
'name': _('Workflow ID'),
'type': 'string',
'private': True,
'required': True,
'regex': (r'^[A-Z0-9_-]+$', 'i'),
},
# Signature
'signature': {
'name': _('Signature'),
'type': 'string',
'private': True,
'required': True,
'regex': (r'^[a-z0-9_-]+$', 'i'),
},
})
# Define our template arguments
template_args = dict(NotifyBase.template_args, **{
'id': {
'alias_of': 'workflow',
},
'image': {
'name': _('Include Image'),
'type': 'bool',
'default': True,
'map_to': 'include_image',
},
'wrap': {
'name': _('Wrap Text'),
'type': 'bool',
'default': True,
'map_to': 'wrap',
},
'template': {
'name': _('Template Path'),
'type': 'string',
'private': True,
},
# Below variable shortforms are taken from the Workflows webhook
# for consistency
'sig': {
'alias_of': 'signature',
},
'ver': {
'name': _('API Version'),
'type': 'string',
'default': '2016-06-01',
'map_to': 'version',
},
'api-version': {
'alias_of': 'ver'
},
})
# Define our token control
template_kwargs = {
'tokens': {
'name': _('Template Tokens'),
'prefix': ':',
},
}
def __init__(self, workflow, signature, include_image=None,
version=None, template=None, tokens=None, wrap=None,
**kwargs):
"""
Initialize Microsoft Workflows Object
"""
super().__init__(**kwargs)
self.workflow = validate_regex(
workflow, *self.template_tokens['workflow']['regex'])
if not self.workflow:
msg = 'An invalid Workflows ID ' \
'({}) was specified.'.format(workflow)
self.logger.warning(msg)
raise TypeError(msg)
self.signature = validate_regex(
signature, *self.template_tokens['signature']['regex'])
if not self.signature:
msg = 'An invalid Signature ' \
'({}) was specified.'.format(signature)
self.logger.warning(msg)
raise TypeError(msg)
# Place a thumbnail image inline with the message body
self.include_image = True if (
include_image if include_image is not None else
self.template_args['image']['default']) else False
# Wrap Text
self.wrap = True if (
wrap if wrap is not None else
self.template_args['wrap']['default']) else False
# Our template object is just an AppriseAttachment object
self.template = AppriseAttachment(asset=self.asset)
if template:
# Add our definition to our template
self.template.add(template)
# Enforce maximum file size
self.template[0].max_file_size = self.max_workflows_template_size
# Prepare Version
self.api_version = version if version is not None \
else self.template_args['ver']['default']
# Template functionality
self.tokens = {}
if isinstance(tokens, dict):
self.tokens.update(tokens)
elif tokens:
msg = 'The specified Workflows Template Tokens ' \
'({}) are not identified as a dictionary.'.format(tokens)
self.logger.warning(msg)
raise TypeError(msg)
# else: NoneType - this is okay
return
def gen_payload(self, body, title='', notify_type=NotifyType.INFO,
**kwargs):
"""
This function generates our payload whether it be the generic one
Apprise generates by default, or one provided by a specified
external template.
"""
# 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)
body_content = []
if image_url:
body_content.append({
"type": "Image",
"url": image_url,
"height": "32px",
"altText": notify_type,
})
if title:
body_content.append({
"type": "TextBlock",
"text": f'{title}',
"style": "heading",
"weight": "Bolder",
"size": "Large",
"id": "title",
})
body_content.append({
"type": "TextBlock",
"text": body,
"style": "default",
"wrap": self.wrap,
"id": "body",
})
if not self.template:
# By default we use a generic working payload if there was
# no template specified
schema = "http://adaptivecards.io/schemas/adaptive-card.json"
payload = {
"type": "message",
"attachments": [
{
"contentType":
"application/vnd.microsoft.card.adaptive",
"contentUrl": None,
"content": {
"$schema": schema,
"type": "AdaptiveCard",
"version": self.adaptive_card_version,
"body": body_content,
# Additionally
"msteams": {"width": "full"},
}
}
]
}
return payload
# If our code reaches here, then we generate ourselves the payload
template = self.template[0]
if not template:
# We could not access the attachment
self.logger.error(
'Could not access Workflow template {}.'.format(
template.url(privacy=True)))
return False
# Take a copy of our token dictionary
tokens = self.tokens.copy()
# Apply some defaults template values
tokens['app_body'] = body
tokens['app_title'] = title
tokens['app_type'] = notify_type
tokens['app_id'] = self.app_id
tokens['app_desc'] = self.app_desc
tokens['app_color'] = self.color(notify_type)
tokens['app_image_url'] = image_url
tokens['app_url'] = self.app_url
# Enforce Application mode
tokens['app_mode'] = TemplateType.JSON
try:
with open(template.path, 'r') as fp:
content = json.loads(apply_template(fp.read(), **tokens))
except (OSError, IOError):
self.logger.error(
'MSTeam template {} could not be read.'.format(
template.url(privacy=True)))
return None
except JSONDecodeError as e:
self.logger.error(
'MSTeam template {} contains invalid JSON.'.format(
template.url(privacy=True)))
self.logger.debug('JSONDecodeError: {}'.format(e))
return None
return content
def send(self, body, title='', notify_type=NotifyType.INFO, **kwargs):
"""
Perform Microsoft Teams Notification
"""
headers = {
'User-Agent': self.app_id,
'Content-Type': 'application/json',
}
params = {
'api-version': self.api_version,
'sp': '/triggers/manual/run',
'sv': '1.0',
'sig': self.signature,
}
notify_url = 'https://{host}{port}/workflows/{workflow}/' \
'triggers/manual/paths/invoke'.format(
host=self.host,
port='' if not self.port else f':{self.port}',
workflow=self.workflow)
# Generate our payload if it's possible
payload = self.gen_payload(
body=body, title=title, notify_type=notify_type, **kwargs)
if not payload:
# No need to present a reason; that will come from the
# gen_payload() function itself
return False
self.logger.debug('Workflows POST URL: %s (cert_verify=%r)' % (
notify_url, self.verify_certificate,
))
self.logger.debug('Workflows Payload: %s' % str(payload))
# Always call throttle before any remote server i/o is made
self.throttle()
try:
r = requests.post(
notify_url,
params=params,
data=json.dumps(payload),
headers=headers,
verify=self.verify_certificate,
timeout=self.request_timeout,
)
if r.status_code not in (
requests.codes.ok, requests.codes.accepted):
# We had a problem
status_str = \
NotifyWorkflows.http_response_code_lookup(r.status_code)
self.logger.warning(
'Failed to send Workflows notification: '
'{}{}error={}.'.format(
status_str,
', ' if status_str else '',
r.status_code))
self.logger.debug(
'Response Details:\r\n{}'.format(r.content))
# We failed
return False
else:
self.logger.info('Sent Workflows notification.')
except requests.RequestException as e:
self.logger.warning(
'A Connection error occurred sending Workflows notification.')
self.logger.debug('Socket Exception: %s' % str(e))
# We failed
return False
return True
def url(self, privacy=False, *args, **kwargs):
"""
Returns the URL built dynamically based on specified arguments.
"""
# Define any URL parameters
params = {
'image': 'yes' if self.include_image else 'no',
'wrap': 'yes' if self.wrap else 'no',
}
if self.template:
params['template'] = NotifyWorkflows.quote(
self.template[0].url(), safe='')
# Store our version if it differs from default
if self.api_version != self.template_args['ver']['default']:
params['ver'] = self.api_version
# Extend our parameters
params.update(self.url_parameters(privacy=privacy, *args, **kwargs))
# Store any template entries if specified
params.update({':{}'.format(k): v for k, v in self.tokens.items()})
return '{schema}://{host}{port}/{workflow}/{signature}/' \
'?{params}'.format(
schema=self.secure_protocol[0],
host=self.host,
port='' if not self.port else f':{self.port}',
workflow=self.pprint(self.workflow, privacy, safe=''),
signature=self.pprint(self.signature, privacy, safe=''),
params=NotifyWorkflows.urlencode(params),
)
@staticmethod
def parse_url(url):
"""
Parses the URL and returns enough arguments that can allow
us to re-instantiate this object.
"""
results = NotifyBase.parse_url(url)
if not results:
# We're done early as we couldn't load the results
return results
# store values if provided
entries = NotifyWorkflows.split_path(results['fullpath'])
# Display image?
results['include_image'] = parse_bool(results['qsd'].get(
'image', NotifyWorkflows.template_args['image']['default']))
# Wrap Text?
results['wrap'] = parse_bool(results['qsd'].get(
'wrap', NotifyWorkflows.template_args['wrap']['default']))
# Template Handling
if 'template' in results['qsd'] and results['qsd']['template']:
results['template'] = \
NotifyWorkflows.unquote(results['qsd']['template'])
if 'workflow' in results['qsd'] and results['qsd']['workflow']:
results['workflow'] = \
NotifyWorkflows.unquote(results['qsd']['workflow'])
elif 'id' in results['qsd'] and results['qsd']['id']:
results['workflow'] = \
NotifyWorkflows.unquote(results['qsd']['id'])
else:
results['workflow'] = None if not entries \
else NotifyWorkflows.unquote(entries.pop(0))
# Signature
if 'signature' in results['qsd'] and results['qsd']['signature']:
results['signature'] = \
NotifyWorkflows.unquote(results['qsd']['signature'])
elif 'sig' in results['qsd'] and results['qsd']['sig']:
results['signature'] = \
NotifyWorkflows.unquote(results['qsd']['sig'])
else:
# Read information from path
results['signature'] = None if not entries \
else NotifyWorkflows.unquote(entries.pop(0))
# Version
if 'api-version' in results['qsd'] and results['qsd']['api-version']:
results['version'] = \
NotifyWorkflows.unquote(results['qsd']['api-version'])
elif 'ver' in results['qsd'] and results['qsd']['ver']:
results['version'] = \
NotifyWorkflows.unquote(results['qsd']['ver'])
# Store our tokens
results['tokens'] = results['qsd:']
return results
@staticmethod
def parse_native_url(url):
"""
Support parsing the webhook straight out of workflows
https://HOST:443/workflows/WORKFLOWID/triggers/manual/paths/invoke
"""
# Match our workflows webhook URL and re-assemble
result = re.match(
r'^https?://(?P<host>[A-Z0-9_.-]+)'
r'(?P<port>:[1-9][0-9]{0,5})?'
r'/workflows/'
r'(?P<workflow>[A-Z0-9_-]+)'
r'/triggers/manual/paths/invoke/?'
r'(?P<params>\?.+)$', url, re.I)
if result:
# Construct our URL
return NotifyWorkflows.parse_url(
'{schema}://{host}{port}/{workflow}'
'/{params}'.format(
schema=NotifyWorkflows.secure_protocol[0],
host=result.group('host'),
port='' if not result.group('port')
else result.group('port'),
workflow=result.group('workflow'),
params=result.group('params')))
return None

View File

@ -51,8 +51,8 @@ PushBullet, Pushjet, PushMe, Pushover, PushSafer, Pushy, PushDeer, Revolt,
Reddit, Rocket.Chat, RSyslog, SendGrid, ServerChan, SFR, Signal, SimplePush,
Sinch, Slack, SMSEagle, SMS Manager, SMTP2Go, SparkPost, Splunk, Super Toasty,
Streamlabs, Stride, Synology Chat, Syslog, Techulus Push, Telegram, Threema
Gateway, Twilio, Twitter, Twist, XBMC, VictorOps, Voipms, Vonage, WeCom Bot,
WhatsApp, Webex Teams}
Gateway, Twilio, Twitter, Twist, VictorOps, Voipms, Vonage, WeCom Bot,
WhatsApp, Webex Teams, Workflows, XBMC}
Name: python-%{pypi_name}
Version: 1.8.0

View File

@ -30,6 +30,7 @@ from itertools import chain
from importlib import import_module, reload
from apprise import NotificationManager
from apprise import AttachmentManager
from apprise import ConfigurationManager
import sys
import re
@ -39,6 +40,9 @@ N_MGR = NotificationManager()
# Grant access to our Attachment Manager Singleton
A_MGR = AttachmentManager()
# Grant access to our Configuration Manager Singleton
C_MGR = ConfigurationManager()
# For filtering our result when scanning a module
# Identify any items below we should match on that we can freely
# directly copy around between our modules. This should only
@ -71,6 +75,21 @@ def reload_plugin(name):
reload(sys.modules[module_pyname])
new_notify_mod = import_module(module_pyname)
A_MGR.unload_modules()
reload(sys.modules['apprise.apprise_config'])
reload(sys.modules['apprise.config.base'])
new_apprise_configuration_mod = import_module('apprise.apprise_config')
new_apprise_config_base_mod = import_module('apprise.config.base')
reload(sys.modules['apprise.manager_config'])
C_MGR.unload_modules()
module_pyname = '{}.{}'.format(N_MGR.module_name_prefix, name)
if module_pyname in sys.modules:
reload(sys.modules[module_pyname])
new_notify_mod = import_module(module_pyname)
# Detect our class object
class_matches = {}
for class_name in [obj for obj in dir(new_notify_mod)
@ -117,15 +136,18 @@ def reload_plugin(name):
for class_name, class_plugin in class_matches.items():
if hasattr(test_mod, class_name):
setattr(test_mod, class_name, class_plugin)
#
# This section below reloads our attachment classes
#
#
# Detect our Apprise Modules (include helpers)
#
apprise_modules = \
sorted([k for k in sys.modules.keys()
if re.match(r'^(apprise|helpers)(\.|.+)$', k)], reverse=True)
#
# This section below reloads our attachment classes
#
for entry in A_MGR:
reload(sys.modules[entry['path']])
for module_pyname in chain(apprise_modules, tests):
@ -173,3 +195,55 @@ def reload_plugin(name):
for class_name, class_plugin in class_matches.items():
if hasattr(apprise_mod, class_name):
setattr(apprise_mod, class_name, class_plugin)
#
# This section below reloads our configuration classes
#
for entry in C_MGR:
reload(sys.modules[entry['path']])
for module_pyname in chain(apprise_modules, tests):
detect = re.compile(
r'^(?P<name>(AppriseConfig|ConfigBase|' +
entry['path'].split('.')[-1] + r'))$')
possible_matches = \
[m for m in dir(sys.modules[module_pyname]) if detect.match(m)]
if not possible_matches:
continue
apprise_mod = import_module(module_pyname)
# Fix reference to new plugin class in given module.
# Needed for updating the module-level import reference
# like `from apprise.<etc> import ConfigABCDE`.
#
# We reload NotifyABCDE and place it back in its spot
# new_attach = import_module(entry['path'])
for name in possible_matches:
if name == 'AppriseConfig':
setattr(
apprise_mod, name,
getattr(new_apprise_configuration_mod, name))
elif name == 'ConfigBase':
setattr(
apprise_mod, name,
getattr(new_apprise_config_base_mod, name))
else:
module_pyname = '{}.{}'.format(
A_MGR.module_name_prefix, name)
new_config_mod = import_module(module_pyname)
# Detect our class object
class_matches = {}
for class_name in [obj for obj in dir(new_config_mod)
if module_filter_re.match(obj)]:
# Store our entry
class_matches[class_name] = \
getattr(new_config_mod, class_name)
for class_name, class_plugin in class_matches.items():
if hasattr(apprise_mod, class_name):
setattr(apprise_mod, class_name, class_plugin)

View File

@ -0,0 +1,434 @@
# -*- coding: utf-8 -*-
# BSD 2-Clause License
#
# Apprise - Push Notification Library.
# Copyright (c) 2024, Chris Caron <lead2gold@gmail.com>
#
# Redistribution and use in source and binary forms, with or without
# modification, are permitted provided that the following conditions are met:
#
# 1. Redistributions of source code must retain the above copyright notice,
# this list of conditions and the following disclaimer.
#
# 2. Redistributions in binary form must reproduce the above copyright notice,
# this list of conditions and the following disclaimer in the documentation
# and/or other materials provided with the distribution.
#
# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
# POSSIBILITY OF SUCH DAMAGE.
from unittest import mock
import json
import requests
import pytest
from apprise import Apprise
from apprise import AppriseConfig
from apprise import NotifyType
from apprise.plugins.workflows import NotifyWorkflows
from helpers import AppriseURLTester
from inspect import cleandoc
# Disable logging for a cleaner testing output
import logging
logging.disable(logging.CRITICAL)
# Our Testing URLs
apprise_url_tests = (
##################################
# NotifyWorkflows
##################################
('workflow://', {
# invalid host details (parsing fails very early)
'instance': None,
}),
('workflow://:@/', {
# invalid host details (parsing fails very early)
'instance': None,
}),
('workflow://host/workflow', {
# workflow provided only, no signature
'instance': TypeError,
}),
('workflow://host:443/^(/signature', {
# invalid workflow provided
'instance': TypeError,
}),
('workflow://host:443/workflow1a/signature/?image=no', {
# All tokens provided - we're good
# Tests case without image defined
'instance': NotifyWorkflows,
}),
('workflows://host:443/workflow1b/signature/', {
# support workflows (s added to end)
'instance': NotifyWorkflows,
}),
('workflows://host:443/signature/?id=workflow1c', {
# id= to store workflow id
'instance': NotifyWorkflows,
}),
('workflows://host:443/signature/?workflow=workflow1d&wrap=yes', {
# workflow= to store workflow id
'instance': NotifyWorkflows,
}),
('workflows://host:443/signature/?workflow=workflow1d&wrap=no', {
# workflow= to store workflow id
'instance': NotifyWorkflows,
}),
('workflows://host:443/workflow1e/signature/?api-version=2024-01-01', {
# support api-version which is extracted from webhook
'instance': NotifyWorkflows,
# Our expected url(privacy=True) startswith() response
'privacy_url': 'workflow://host:443/w...e/s...e/',
}),
('workflows://host:443/workflow1b/signature/?ver=2016-06-01', {
# Support ver= (api-version alias)
'instance': NotifyWorkflows,
}),
('workflows://host:443/?id=workflow1b&signature=signature', {
# Support signature= (sig= alias)
'instance': NotifyWorkflows,
# Our expected url(privacy=True) startswith() response
'privacy_url': 'workflow://host:443/w...b/s...e/',
}),
# Support native URLs
('https://server.azure.com:443/workflows/643e69f83c8944/'
'triggers/manual/paths/invoke?'
'api-version=2016-06-01&sp=%2Ftriggers%2Fmanual%2Frun&'
'sv=1.0&sig=KODuebWbDGYFr0z0eu', {
# All tokens provided - we're good
'instance': NotifyWorkflows,
# Our expected url(privacy=True) startswith() response
'privacy_url': 'workflow://server.azure.com:443/6...4/K...u/'}),
('workflow://host:443/workflow2/signature/', {
'instance': NotifyWorkflows,
# force a failure
'response': False,
'requests_response_code': requests.codes.internal_server_error,
}),
('workflow://host:443/workflow3/signature/', {
'instance': NotifyWorkflows,
# throw a bizzare code forcing us to fail to look it up
'response': False,
'requests_response_code': 999,
}),
('workflow://host:443/workflow4/signature/', {
'instance': NotifyWorkflows,
# Throws a series of connection and transfer exceptions when this flag
# is set and tests that we gracfully handle them
'test_requests_exceptions': True,
}),
)
def test_plugin_workflows_urls():
"""
NotifyWorkflows() Apprise URLs
"""
# Run our general tests
AppriseURLTester(tests=apprise_url_tests).run_all()
@pytest.fixture
def workflows_url():
return 'workflow://host:443/workflow/signature'
@pytest.fixture
def request_mock(mocker):
"""
Prepare requests mock.
"""
mock_post = mocker.patch("requests.post")
mock_post.return_value = requests.Request()
mock_post.return_value.status_code = requests.codes.ok
return mock_post
@pytest.fixture
def simple_template(tmpdir):
template = tmpdir.join("simple.json")
template.write(cleandoc("""
{
"type": "message",
"attachments": [{
"contentType": "application/vnd.microsoft.card.adaptive",
"contentUrl": None,
"content": {
"$schema":"http://adaptivecards.io/schemas/adaptive-card.json",
"type": "AdaptiveCard",
"version": "1.4",
"msteams": { "width": "full" },
"body": [
{
"type": "TextBlock",
"text": "**Test**",
"style": "heading"
},
]
}
]
}
"""))
return template
def test_plugin_workflows_templating_basic_success(
request_mock, workflows_url, tmpdir):
"""
NotifyWorkflows() Templating - success.
Test cases where URL and JSON is valid.
"""
template = tmpdir.join("simple.json")
template.write(cleandoc("""
{
"@type": "MessageCard",
"@context": "https://schema.org/extensions",
"summary": "{{app_id}}",
"themeColor": "{{app_color}}",
"sections": [
{
"activityImage": null,
"activityTitle": "{{app_title}}",
"text": "{{app_body}}"
}
]
}
"""))
# Instantiate our URL
obj = Apprise.instantiate('{url}/?template={template}&{kwargs}'.format(
url=workflows_url,
template=str(template),
kwargs=':key1=token&:key2=token',
))
assert isinstance(obj, NotifyWorkflows)
assert obj.notify(
body="body", title='title',
notify_type=NotifyType.INFO) is True
assert request_mock.called is True
assert request_mock.call_args_list[0][0][0].startswith(
'https://host:443/workflows/workflow/triggers/manual/paths/invoke')
# Our Posted JSON Object
posted_json = json.loads(request_mock.call_args_list[0][1]['data'])
assert 'summary' in posted_json
assert posted_json['summary'] == 'Apprise'
assert posted_json['themeColor'] == '#3AA3E3'
assert posted_json['sections'][0]['activityTitle'] == 'title'
assert posted_json['sections'][0]['text'] == 'body'
def test_plugin_workflows_templating_invalid_json(
request_mock, workflows_url, tmpdir):
"""
NotifyWorkflows() Templating - invalid JSON.
"""
template = tmpdir.join("invalid.json")
template.write("}")
# Instantiate our URL
obj = Apprise.instantiate('{url}/?template={template}&{kwargs}'.format(
url=workflows_url,
template=str(template),
kwargs=':key1=token&:key2=token',
))
assert isinstance(obj, NotifyWorkflows)
# We will fail to preform our notifcation because the JSON is bad
assert obj.notify(
body="body", title='title',
notify_type=NotifyType.INFO) is False
def test_plugin_workflows_templating_load_json_failure(
request_mock, workflows_url, tmpdir):
"""
NotifyWorkflows() Templating - template loading failure.
Test a case where we can not access the file.
"""
template = tmpdir.join("empty.json")
template.write("")
obj = Apprise.instantiate('{url}/?template={template}'.format(
url=workflows_url,
template=str(template),
))
with mock.patch('json.loads', side_effect=OSError):
# we fail, but this time it's because we couldn't
# access the cached file contents for reading
assert obj.notify(
body="body", title='title',
notify_type=NotifyType.INFO) is False
def test_plugin_workflows_templating_target_success(
request_mock, workflows_url, tmpdir):
"""
NotifyWorkflows() Templating - success with target.
A more complicated example; uses a target.
"""
template = tmpdir.join("more_complicated_example.json")
template.write(cleandoc("""
{
"@type": "MessageCard",
"@context": "https://schema.org/extensions",
"summary": "{{app_desc}}",
"themeColor": "{{app_color}}",
"sections": [
{
"activityImage": null,
"activityTitle": "{{app_title}}",
"text": "{{app_body}}"
}
],
"potentialAction": [{
"@type": "ActionCard",
"name": "Add a comment",
"inputs": [{
"@type": "TextInput",
"id": "comment",
"isMultiline": false,
"title": "Add a comment here for this task."
}],
"actions": [{
"@type": "HttpPOST",
"name": "Add Comment",
"target": "{{ target }}"
}]
}]
}
"""))
# Instantiate our URL
obj = Apprise.instantiate('{url}/?template={template}&{kwargs}'.format(
url=workflows_url,
template=str(template),
kwargs=':key1=token&:key2=token&:target=http://localhost',
))
assert isinstance(obj, NotifyWorkflows)
assert obj.notify(
body="body", title='title',
notify_type=NotifyType.INFO) is True
assert request_mock.called is True
assert request_mock.call_args_list[0][0][0].startswith(
'https://host:443/workflows/workflow/triggers/manual/paths/invoke')
# Our Posted JSON Object
posted_json = json.loads(request_mock.call_args_list[0][1]['data'])
assert 'summary' in posted_json
assert posted_json['summary'] == 'Apprise Notifications'
assert posted_json['themeColor'] == '#3AA3E3'
assert posted_json['sections'][0]['activityTitle'] == 'title'
assert posted_json['sections'][0]['text'] == 'body'
# We even parsed our entry out of the URL
assert posted_json['potentialAction'][0]['actions'][0]['target'] \
== 'http://localhost'
def test_workflows_yaml_config_missing_template_filename(
request_mock, workflows_url, simple_template, tmpdir):
"""
NotifyWorkflows() YAML Configuration Entries - Missing template reference.
"""
config = tmpdir.join("workflow01.yml")
config.write(cleandoc("""
urls:
- {url}:
- tag: 'workflow'
template: {template}.missing
:name: 'Template.Missing'
:body: 'test body'
:title: 'test title'
""".format(url=workflows_url, template=str(simple_template))))
# Config still loads okay
cfg = AppriseConfig()
cfg.add(str(config))
assert len(cfg) == 1
assert len(cfg[0]) == 1
obj = cfg[0][0]
assert isinstance(obj, NotifyWorkflows)
# However we can't send notification since the template couldn't be loaded
assert obj.notify(
body="body", title='title',
notify_type=NotifyType.INFO) is False
assert request_mock.called is False
def test_plugin_workflows_edge_cases():
"""
NotifyWorkflows() Edge Cases
"""
# Initializes the plugin with an invalid token
with pytest.raises(TypeError):
NotifyWorkflows(workflow='@', signature='@')
with pytest.raises(TypeError):
NotifyWorkflows(workflow='', signature='abcd')
with pytest.raises(TypeError):
NotifyWorkflows(workflow=None, signature='abcd')
# Whitespace also acts as an invalid token value
with pytest.raises(TypeError):
NotifyWorkflows(workflow=' ', signature='abcd')
with pytest.raises(TypeError):
NotifyWorkflows(workflow='abcd', signature=None)
# Whitespace also acts as an invalid token value
with pytest.raises(TypeError):
NotifyWorkflows(workflow='abcd', signature=' ')
# test case where invalid tokens are specified
with pytest.raises(TypeError):
NotifyWorkflows(
workflow='workflow', signature='signature', tokens='not-a-dict')
# test case where no tokens are specified
obj = NotifyWorkflows(workflow='workflow', signature='signature')
assert isinstance(obj, NotifyWorkflows)
def test_plugin_workflows_azure_webhooks(request_mock):
"""
NotifyWorkflows() Azure Webhooks
"""
url = 'https://prod-15.uksouth.logic.azure.com:443' \
'/workflows/3XXX5/triggers/manual/paths/invoke' \
'?api-version=2016-06-01&' \
'sp=%2Ftriggers%2Fmanual%2Frun&sv=1.0&sig=iXXXU'
#
# Initialize
#
obj = Apprise.instantiate(url)
assert isinstance(obj, NotifyWorkflows)
assert obj.workflow == "3XXX5"
assert obj.signature == "iXXXU"
assert obj.api_version == "2016-06-01"