diff --git a/KEYWORDS b/KEYWORDS
index 4ebf6f2c..b67e068a 100644
--- a/KEYWORDS
+++ b/KEYWORDS
@@ -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
diff --git a/README.md b/README.md
index 04ac4baa..0a6f66db 100644
--- a/README.md
+++ b/README.md
@@ -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
mastodon://access_key@hostname/@user
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
matrix://user@hostname
matrixs://user:pass@hostname:port/#room_alias
matrixs://user:pass@hostname:port/!room_id
matrixs://user:pass@hostname:port/#room_alias/!room_id/#room2
matrixs://token@hostname:port/?webhook=matrix
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
mmost://hostname:80/authkey
mmost://user@hostname:80/authkey
mmost://hostname/authkey?channel=channel
mmosts://hostname/authkey
mmosts://user@hostname/authkey
+| [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
mqtt://user@hostname/topic
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
tgram://bottoken/ChatID1/ChatID2/ChatIDN
| [Twitter](https://github.com/caronc/apprise/wiki/Notify_twitter) | twitter:// | (TCP) 443 | twitter://CKey/CSecret/AKey/ASecret
twitter://user@CKey/CSecret/AKey/ASecret
twitter://CKey/CSecret/AKey/ASecret/User1/User2/User2
twitter://CKey/CSecret/AKey/ASecret?mode=tweet
| [Twist](https://github.com/caronc/apprise/wiki/Notify_twist) | twist:// | (TCP) 443 | twist://pasword:login
twist://password:login/#channel
twist://password:login/#team:channel
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
xbmc://user@hostname
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
whatsapp://Template:AccessToken@FromPhoneID/ToPhoneNo
+| [XBMC](https://github.com/caronc/apprise/wiki/Notify_xbmc) | xbmc:// or xbmcs:// | (TCP) 8080 or 443 | xbmc://hostname
xbmc://user@hostname
xbmc://user:password@hostname:port
| [Zulip Chat](https://github.com/caronc/apprise/wiki/Notify_zulip) | zulip:// | (TCP) 443 | zulip://botname@Organization/Token
zulip://botname@Organization/Token/Stream
zulip://botname@Organization/Token/Email
## SMS Notifications
diff --git a/apprise/plugins/msteams.py b/apprise/plugins/msteams.py
index 2e0957f3..1e1925f6 100644
--- a/apprise/plugins/msteams.py
+++ b/apprise/plugins/msteams.py
@@ -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,
diff --git a/apprise/plugins/workflows.py b/apprise/plugins/workflows.py
new file mode 100644
index 00000000..4e9d80e0
--- /dev/null
+++ b/apprise/plugins/workflows.py
@@ -0,0 +1,565 @@
+# -*- coding: utf-8 -*-
+# BSD 2-Clause License
+#
+# Apprise - Push Notification Library.
+# Copyright (c) 2024, Chris Caron
+#
+# 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[A-Z0-9_.-]+)'
+ r'(?P:[1-9][0-9]{0,5})?'
+ r'/workflows/'
+ r'(?P[A-Z0-9_-]+)'
+ r'/triggers/manual/paths/invoke/?'
+ r'(?P\?.+)$', 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
diff --git a/packaging/redhat/python-apprise.spec b/packaging/redhat/python-apprise.spec
index 339d5c03..e483cc98 100644
--- a/packaging/redhat/python-apprise.spec
+++ b/packaging/redhat/python-apprise.spec
@@ -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
diff --git a/test/helpers/module.py b/test/helpers/module.py
index d991a96d..4b4db5eb 100644
--- a/test/helpers/module.py
+++ b/test/helpers/module.py
@@ -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(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. 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)
diff --git a/test/test_plugin_workflows.py b/test/test_plugin_workflows.py
new file mode 100644
index 00000000..b5bce015
--- /dev/null
+++ b/test/test_plugin_workflows.py
@@ -0,0 +1,434 @@
+# -*- coding: utf-8 -*-
+# BSD 2-Clause License
+#
+# Apprise - Push Notification Library.
+# Copyright (c) 2024, Chris Caron
+#
+# 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"