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"