From bb7c77105e050f586de76434d70647a573e01932 Mon Sep 17 00:00:00 2001 From: Chris Caron Date: Sun, 15 Aug 2021 19:47:24 -0400 Subject: [PATCH] Attachment Support for JSON & XML Notifications (#426) --- apprise/assets/NotifyXML-1.0.xsd | 39 +++++------ apprise/assets/NotifyXML-1.1.xsd | 40 ++++++++++++ apprise/plugins/NotifyJSON.py | 51 ++++++++++++--- apprise/plugins/NotifyXML.py | 59 ++++++++++++++++- test/test_custom_json_plugin.py | 108 +++++++++++++++++++++++++++++++ test/test_custom_xml_plugin.py | 108 +++++++++++++++++++++++++++++++ 6 files changed, 374 insertions(+), 31 deletions(-) create mode 100644 apprise/assets/NotifyXML-1.1.xsd create mode 100644 test/test_custom_json_plugin.py create mode 100644 test/test_custom_xml_plugin.py diff --git a/apprise/assets/NotifyXML-1.0.xsd b/apprise/assets/NotifyXML-1.0.xsd index d9b7235a..0e3f8f13 100644 --- a/apprise/assets/NotifyXML-1.0.xsd +++ b/apprise/assets/NotifyXML-1.0.xsd @@ -1,22 +1,23 @@ - + + - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + diff --git a/apprise/assets/NotifyXML-1.1.xsd b/apprise/assets/NotifyXML-1.1.xsd new file mode 100644 index 00000000..cc6dbae6 --- /dev/null +++ b/apprise/assets/NotifyXML-1.1.xsd @@ -0,0 +1,40 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apprise/plugins/NotifyJSON.py b/apprise/plugins/NotifyJSON.py index c42a6970..c0367033 100644 --- a/apprise/plugins/NotifyJSON.py +++ b/apprise/plugins/NotifyJSON.py @@ -25,6 +25,7 @@ import six import requests +import base64 from json import dumps from .NotifyBase import NotifyBase @@ -160,11 +161,50 @@ class NotifyJSON(NotifyBase): params=NotifyJSON.urlencode(params), ) - def send(self, body, title='', notify_type=NotifyType.INFO, **kwargs): + def send(self, body, title='', notify_type=NotifyType.INFO, attach=None, + **kwargs): """ Perform JSON Notification """ + headers = { + 'User-Agent': self.app_id, + 'Content-Type': 'application/json' + } + + # Apply any/all header over-rides defined + headers.update(self.headers) + + # Track our potential attachments + attachments = [] + if attach: + for attachment in attach: + # Perform some simple error checking + if not attachment: + # We could not access the attachment + self.logger.error( + 'Could not access attachment {}.'.format( + attachment.url(privacy=True))) + return False + + try: + with open(attachment.path, 'rb') as f: + # Output must be in a DataURL format (that's what + # PushSafer calls it): + attachments.append({ + 'filename': attachment.name, + 'base64': base64.b64encode(f.read()) + .decode('utf-8'), + 'mimetype': attachment.mimetype, + }) + + except (OSError, IOError) as e: + self.logger.warning( + 'An I/O error occurred while reading {}.'.format( + attachment.name if attachment else 'attachment')) + self.logger.debug('I/O Exception: %s' % str(e)) + return False + # prepare JSON Object payload = { # Version: Major.Minor, Major is only updated if the entire @@ -173,17 +213,10 @@ class NotifyJSON(NotifyBase): 'version': '1.0', 'title': title, 'message': body, + 'attachments': attachments, 'type': notify_type, } - headers = { - 'User-Agent': self.app_id, - 'Content-Type': 'application/json' - } - - # Apply any/all header over-rides defined - headers.update(self.headers) - auth = None if self.user: auth = (self.user, self.password) diff --git a/apprise/plugins/NotifyXML.py b/apprise/plugins/NotifyXML.py index aabfbbed..39438fad 100644 --- a/apprise/plugins/NotifyXML.py +++ b/apprise/plugins/NotifyXML.py @@ -26,6 +26,7 @@ import re import six import requests +import base64 from .NotifyBase import NotifyBase from ..URLBase import PrivacyMode @@ -58,6 +59,11 @@ class NotifyXML(NotifyBase): # local anyway request_rate_per_sec = 0 + # XSD Information + xsd_ver = '1.1' + xsd_url = 'https://raw.githubusercontent.com/caronc/apprise/master' \ + '/apprise/assets/NotifyXML-{version}.xsd' + # Define object templates templates = ( '{schema}://{host}', @@ -118,11 +124,12 @@ class NotifyXML(NotifyBase): xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"> - - 1.0 + + {XSD_VER} {SUBJECT} {MESSAGE_TYPE} {MESSAGE} + {ATTACHMENTS} """ @@ -175,7 +182,8 @@ class NotifyXML(NotifyBase): params=NotifyXML.urlencode(params), ) - def send(self, body, title='', notify_type=NotifyType.INFO, **kwargs): + def send(self, body, title='', notify_type=NotifyType.INFO, attach=None, + **kwargs): """ Perform XML Notification """ @@ -189,11 +197,55 @@ class NotifyXML(NotifyBase): # Apply any/all header over-rides defined headers.update(self.headers) + # Our XML Attachmement subsitution + xml_attachments = '' + + # Track our potential attachments + attachments = [] + if attach: + for attachment in attach: + # Perform some simple error checking + if not attachment: + # We could not access the attachment + self.logger.error( + 'Could not access attachment {}.'.format( + attachment.url(privacy=True))) + return False + + try: + with open(attachment.path, 'rb') as f: + # Output must be in a DataURL format (that's what + # PushSafer calls it): + entry = \ + ''.format( + NotifyXML.escape_html( + attachment.name, whitespace=False), + NotifyXML.escape_html( + attachment.mimetype, whitespace=False)) + entry += base64.b64encode(f.read()).decode('utf-8') + entry += '' + attachments.append(entry) + + except (OSError, IOError) as e: + self.logger.warning( + 'An I/O error occurred while reading {}.'.format( + attachment.name if attachment else 'attachment')) + self.logger.debug('I/O Exception: %s' % str(e)) + return False + + # Update our xml_attachments record: + xml_attachments = \ + '' + \ + ''.join(attachments) + '' + re_map = { + '{XSD_VER}': self.xsd_ver, + '{XSD_URL}': self.xsd_url.format(version=self.xsd_ver), '{MESSAGE_TYPE}': NotifyXML.escape_html( notify_type, whitespace=False), '{SUBJECT}': NotifyXML.escape_html(title, whitespace=False), '{MESSAGE}': NotifyXML.escape_html(body, whitespace=False), + '{ATTACHMENTS}': xml_attachments, } # Iterate over above list and store content accordingly @@ -219,6 +271,7 @@ class NotifyXML(NotifyBase): self.logger.debug('XML POST URL: %s (cert_verify=%r)' % ( url, self.verify_certificate, )) + self.logger.debug('XML Payload: %s' % str(payload)) # Always call throttle before any remote server i/o is made diff --git a/test/test_custom_json_plugin.py b/test/test_custom_json_plugin.py new file mode 100644 index 00000000..4885e414 --- /dev/null +++ b/test/test_custom_json_plugin.py @@ -0,0 +1,108 @@ +# -*- coding: utf-8 -*- +# +# Copyright (C) 2021 Chris Caron +# All rights reserved. +# +# This code is licensed under the MIT License. +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files(the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and / or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions : +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. + +import os +import sys +import mock +import requests +from apprise import plugins +from apprise import Apprise +from apprise import AppriseAttachment +from apprise import NotifyType + +# Disable logging for a cleaner testing output +import logging +logging.disable(logging.CRITICAL) + +# Attachment Directory +TEST_VAR_DIR = os.path.join(os.path.dirname(__file__), 'var') + + +@mock.patch('requests.post') +def test_notify_json_plugin_attachments(mock_post): + """ + API: NotifyJSON() Attachments + + """ + # Disable Throttling to speed testing + plugins.NotifyBase.request_rate_per_sec = 0 + + okay_response = requests.Request() + okay_response.status_code = requests.codes.ok + okay_response.content = "" + + # Assign our mock object our return value + mock_post.return_value = okay_response + + obj = Apprise.instantiate('json://localhost.localdomain/') + assert isinstance(obj, plugins.NotifyJSON) + + # Test Valid Attachment + path = os.path.join(TEST_VAR_DIR, 'apprise-test.gif') + attach = AppriseAttachment(path) + assert obj.notify( + body='body', title='title', notify_type=NotifyType.INFO, + attach=attach) is True + + # Test invalid attachment + path = os.path.join(TEST_VAR_DIR, '/invalid/path/to/an/invalid/file.jpg') + assert obj.notify( + body='body', title='title', notify_type=NotifyType.INFO, + attach=path) is False + + # Get a appropriate "builtin" module name for pythons 2/3. + if sys.version_info.major >= 3: + builtin_open_function = 'builtins.open' + + else: + builtin_open_function = '__builtin__.open' + + # Test Valid Attachment (load 3) + path = ( + os.path.join(TEST_VAR_DIR, 'apprise-test.gif'), + os.path.join(TEST_VAR_DIR, 'apprise-test.gif'), + os.path.join(TEST_VAR_DIR, 'apprise-test.gif'), + ) + attach = AppriseAttachment(path) + + # Return our good configuration + mock_post.side_effect = None + mock_post.return_value = okay_response + with mock.patch(builtin_open_function, side_effect=OSError()): + # We can't send the message we can't open the attachment for reading + assert obj.notify( + body='body', title='title', notify_type=NotifyType.INFO, + attach=attach) is False + + # test the handling of our batch modes + obj = Apprise.instantiate('json://no-reply@example.com/') + assert isinstance(obj, plugins.NotifyJSON) + + # Now send an attachment normally without issues + mock_post.reset_mock() + assert obj.notify( + body='body', title='title', notify_type=NotifyType.INFO, + attach=attach) is True + assert mock_post.call_count == 1 diff --git a/test/test_custom_xml_plugin.py b/test/test_custom_xml_plugin.py new file mode 100644 index 00000000..43e0794f --- /dev/null +++ b/test/test_custom_xml_plugin.py @@ -0,0 +1,108 @@ +# -*- coding: utf-8 -*- +# +# Copyright (C) 2021 Chris Caron +# All rights reserved. +# +# This code is licensed under the MIT License. +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files(the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and / or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions : +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. + +import os +import sys +import mock +import requests +from apprise import plugins +from apprise import Apprise +from apprise import AppriseAttachment +from apprise import NotifyType + +# Disable logging for a cleaner testing output +import logging +logging.disable(logging.CRITICAL) + +# Attachment Directory +TEST_VAR_DIR = os.path.join(os.path.dirname(__file__), 'var') + + +@mock.patch('requests.post') +def test_notify_xml_plugin_attachments(mock_post): + """ + API: NotifyXML() Attachments + + """ + # Disable Throttling to speed testing + plugins.NotifyBase.request_rate_per_sec = 0 + + okay_response = requests.Request() + okay_response.status_code = requests.codes.ok + okay_response.content = "" + + # Assign our mock object our return value + mock_post.return_value = okay_response + + obj = Apprise.instantiate('xml://localhost.localdomain/') + assert isinstance(obj, plugins.NotifyXML) + + # Test Valid Attachment + path = os.path.join(TEST_VAR_DIR, 'apprise-test.gif') + attach = AppriseAttachment(path) + assert obj.notify( + body='body', title='title', notify_type=NotifyType.INFO, + attach=attach) is True + + # Test invalid attachment + path = os.path.join(TEST_VAR_DIR, '/invalid/path/to/an/invalid/file.jpg') + assert obj.notify( + body='body', title='title', notify_type=NotifyType.INFO, + attach=path) is False + + # Get a appropriate "builtin" module name for pythons 2/3. + if sys.version_info.major >= 3: + builtin_open_function = 'builtins.open' + + else: + builtin_open_function = '__builtin__.open' + + # Test Valid Attachment (load 3) + path = ( + os.path.join(TEST_VAR_DIR, 'apprise-test.gif'), + os.path.join(TEST_VAR_DIR, 'apprise-test.gif'), + os.path.join(TEST_VAR_DIR, 'apprise-test.gif'), + ) + attach = AppriseAttachment(path) + + # Return our good configuration + mock_post.side_effect = None + mock_post.return_value = okay_response + with mock.patch(builtin_open_function, side_effect=OSError()): + # We can't send the message we can't open the attachment for reading + assert obj.notify( + body='body', title='title', notify_type=NotifyType.INFO, + attach=attach) is False + + # test the handling of our batch modes + obj = Apprise.instantiate('xml://no-reply@example.com/') + assert isinstance(obj, plugins.NotifyXML) + + # Now send an attachment normally without issues + mock_post.reset_mock() + assert obj.notify( + body='body', title='title', notify_type=NotifyType.INFO, + attach=attach) is True + assert mock_post.call_count == 1