Attachment Support for JSON & XML Notifications (#426)

This commit is contained in:
Chris Caron 2021-08-15 19:47:24 -04:00 committed by GitHub
parent 836b729a20
commit bb7c77105e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 374 additions and 31 deletions

View File

@ -1,10 +1,12 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<xs:schema elementFormDefault="qualified" xmlns:xs="http://www.w3.org/2001/XMLSchema"> <xs:schema attributeFormDefault="unqualified" elementFormDefault="qualified" xmlns:xs="http://www.w3.org/2001/XMLSchema">
<xs:import namespace="http://schemas.xmlsoap.org/soap/envelope/" schemaLocation="http://schemas.xmlsoap.org/soap/envelope/"/>
<xs:element name="Notification"> <xs:element name="Notification">
<xs:complexType> <xs:complexType>
<xs:sequence> <xs:sequence>
<xs:element name="Version" type="xs:string" /> <xs:element name="Version" type="xs:string" />
<xs:element name="MessageType" type="xs:string" /> <xs:element name="Subject" type="xs:string" />
<xs:element name="MessageType">
<xs:simpleType> <xs:simpleType>
<xs:restriction base="xs:string"> <xs:restriction base="xs:string">
<xs:enumeration value="success" /> <xs:enumeration value="success" />
@ -14,7 +16,6 @@
</xs:restriction> </xs:restriction>
</xs:simpleType> </xs:simpleType>
</xs:element> </xs:element>
<xs:element name="Subject" type="xs:string" />
<xs:element name="Message" type="xs:string" /> <xs:element name="Message" type="xs:string" />
</xs:sequence> </xs:sequence>
</xs:complexType> </xs:complexType>

View File

@ -0,0 +1,40 @@
<?xml version="1.0" encoding="utf-8"?>
<xs:schema attributeFormDefault="unqualified" elementFormDefault="qualified" xmlns:xs="http://www.w3.org/2001/XMLSchema">
<xs:import namespace="http://schemas.xmlsoap.org/soap/envelope/" schemaLocation="http://schemas.xmlsoap.org/soap/envelope/"/>
<xs:element name="Notification">
<xs:complexType>
<xs:sequence>
<xs:element name="Version" type="xs:string" />
<xs:element name="Subject" type="xs:string" />
<xs:element name="MessageType">
<xs:simpleType>
<xs:restriction base="xs:string">
<xs:enumeration value="success" />
<xs:enumeration value="failure" />
<xs:enumeration value="info" />
<xs:enumeration value="warning" />
</xs:restriction>
</xs:simpleType>
</xs:element>
<xs:element name="Message" type="xs:string" />
<xs:element name="Attachments" minOccurs="0">
<xs:complexType>
<xs:sequence>
<xs:element name="Attachment" minOccurs="0" maxOccurs="unbounded">
<xs:complexType>
<xs:simpleContent>
<xs:extension base="xs:string">
<xs:attribute name="mimetype" type="xs:string" use="required"/>
<xs:attribute name="filename" type="xs:string" use="required"/>
</xs:extension>
</xs:simpleContent>
</xs:complexType>
</xs:element>
</xs:sequence>
<xs:attribute name="encoding" type="xs:string" use="required"/>
</xs:complexType>
</xs:element>
</xs:sequence>
</xs:complexType>
</xs:element>
</xs:schema>

View File

@ -25,6 +25,7 @@
import six import six
import requests import requests
import base64
from json import dumps from json import dumps
from .NotifyBase import NotifyBase from .NotifyBase import NotifyBase
@ -160,11 +161,50 @@ class NotifyJSON(NotifyBase):
params=NotifyJSON.urlencode(params), 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 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 # prepare JSON Object
payload = { payload = {
# Version: Major.Minor, Major is only updated if the entire # Version: Major.Minor, Major is only updated if the entire
@ -173,17 +213,10 @@ class NotifyJSON(NotifyBase):
'version': '1.0', 'version': '1.0',
'title': title, 'title': title,
'message': body, 'message': body,
'attachments': attachments,
'type': notify_type, '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 auth = None
if self.user: if self.user:
auth = (self.user, self.password) auth = (self.user, self.password)

View File

@ -26,6 +26,7 @@
import re import re
import six import six
import requests import requests
import base64
from .NotifyBase import NotifyBase from .NotifyBase import NotifyBase
from ..URLBase import PrivacyMode from ..URLBase import PrivacyMode
@ -58,6 +59,11 @@ class NotifyXML(NotifyBase):
# local anyway # local anyway
request_rate_per_sec = 0 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 # Define object templates
templates = ( templates = (
'{schema}://{host}', '{schema}://{host}',
@ -118,11 +124,12 @@ class NotifyXML(NotifyBase):
xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:xsd="http://www.w3.org/2001/XMLSchema"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"> xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
<soapenv:Body> <soapenv:Body>
<Notification xmlns:xsi="http://nuxref.com/apprise/NotifyXML-1.0.xsd"> <Notification xmlns:xsi="{XSD_URL}">
<Version>1.0</Version> <Version>{XSD_VER}</Version>
<Subject>{SUBJECT}</Subject> <Subject>{SUBJECT}</Subject>
<MessageType>{MESSAGE_TYPE}</MessageType> <MessageType>{MESSAGE_TYPE}</MessageType>
<Message>{MESSAGE}</Message> <Message>{MESSAGE}</Message>
{ATTACHMENTS}
</Notification> </Notification>
</soapenv:Body> </soapenv:Body>
</soapenv:Envelope>""" </soapenv:Envelope>"""
@ -175,7 +182,8 @@ class NotifyXML(NotifyBase):
params=NotifyXML.urlencode(params), 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 Perform XML Notification
""" """
@ -189,11 +197,55 @@ class NotifyXML(NotifyBase):
# Apply any/all header over-rides defined # Apply any/all header over-rides defined
headers.update(self.headers) 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 = \
'<Attachment filename="{}" mimetype="{}">'.format(
NotifyXML.escape_html(
attachment.name, whitespace=False),
NotifyXML.escape_html(
attachment.mimetype, whitespace=False))
entry += base64.b64encode(f.read()).decode('utf-8')
entry += '</Attachment>'
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 = \
'<Attachments format="base64">' + \
''.join(attachments) + '</Attachments>'
re_map = { re_map = {
'{XSD_VER}': self.xsd_ver,
'{XSD_URL}': self.xsd_url.format(version=self.xsd_ver),
'{MESSAGE_TYPE}': NotifyXML.escape_html( '{MESSAGE_TYPE}': NotifyXML.escape_html(
notify_type, whitespace=False), notify_type, whitespace=False),
'{SUBJECT}': NotifyXML.escape_html(title, whitespace=False), '{SUBJECT}': NotifyXML.escape_html(title, whitespace=False),
'{MESSAGE}': NotifyXML.escape_html(body, whitespace=False), '{MESSAGE}': NotifyXML.escape_html(body, whitespace=False),
'{ATTACHMENTS}': xml_attachments,
} }
# Iterate over above list and store content accordingly # 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)' % ( self.logger.debug('XML POST URL: %s (cert_verify=%r)' % (
url, self.verify_certificate, url, self.verify_certificate,
)) ))
self.logger.debug('XML Payload: %s' % str(payload)) self.logger.debug('XML Payload: %s' % str(payload))
# Always call throttle before any remote server i/o is made # Always call throttle before any remote server i/o is made

View File

@ -0,0 +1,108 @@
# -*- coding: utf-8 -*-
#
# Copyright (C) 2021 Chris Caron <lead2gold@gmail.com>
# 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

View File

@ -0,0 +1,108 @@
# -*- coding: utf-8 -*-
#
# Copyright (C) 2021 Chris Caron <lead2gold@gmail.com>
# 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