Apprise API FORM based Attachment Support Added (#877)

This commit is contained in:
Chris Caron 2023-05-15 15:21:03 -04:00 committed by GitHub
parent b0e64126e6
commit 80ae7228e9
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
2 changed files with 156 additions and 62 deletions

View File

@ -43,6 +43,20 @@ from ..utils import validate_regex
from ..AppriseLocale import gettext_lazy as _ from ..AppriseLocale import gettext_lazy as _
class AppriseAPIMethod:
"""
Defines the method to post data tot he remote server
"""
JSON = 'json'
FORM = 'form'
APPRISE_API_METHODS = (
AppriseAPIMethod.FORM,
AppriseAPIMethod.JSON,
)
class NotifyAppriseAPI(NotifyBase): class NotifyAppriseAPI(NotifyBase):
""" """
A wrapper for Apprise (Persistent) API Notifications A wrapper for Apprise (Persistent) API Notifications
@ -65,7 +79,7 @@ class NotifyAppriseAPI(NotifyBase):
# Depending on the number of transactions/notifications taking place, this # Depending on the number of transactions/notifications taking place, this
# could take a while. 30 seconds should be enough to perform the task # could take a while. 30 seconds should be enough to perform the task
socket_connect_timeout = 30.0 socket_read_timeout = 30.0
# Disable throttle rate for Apprise API requests since they are normally # Disable throttle rate for Apprise API requests since they are normally
# local anyway # local anyway
@ -120,6 +134,12 @@ class NotifyAppriseAPI(NotifyBase):
'name': _('Tags'), 'name': _('Tags'),
'type': 'string', 'type': 'string',
}, },
'method': {
'name': _('Query Method'),
'type': 'choice:string',
'values': APPRISE_API_METHODS,
'default': APPRISE_API_METHODS[0],
},
'to': { 'to': {
'alias_of': 'token', 'alias_of': 'token',
}, },
@ -133,7 +153,8 @@ class NotifyAppriseAPI(NotifyBase):
}, },
} }
def __init__(self, token=None, tags=None, headers=None, **kwargs): def __init__(self, token=None, tags=None, method=None, headers=None,
**kwargs):
""" """
Initialize Apprise API Object Initialize Apprise API Object
@ -155,6 +176,14 @@ class NotifyAppriseAPI(NotifyBase):
self.logger.warning(msg) self.logger.warning(msg)
raise TypeError(msg) raise TypeError(msg)
self.method = self.template_args['method']['default'] \
if not isinstance(method, str) else method.lower()
if self.method not in APPRISE_API_METHODS:
msg = 'The method specified ({}) is invalid.'.format(method)
self.logger.warning(msg)
raise TypeError(msg)
# Build list of tags # Build list of tags
self.__tags = parse_list(tags) self.__tags = parse_list(tags)
@ -170,8 +199,13 @@ class NotifyAppriseAPI(NotifyBase):
Returns the URL built dynamically based on specified arguments. Returns the URL built dynamically based on specified arguments.
""" """
# Our URL parameters # Define any URL parameters
params = self.url_parameters(privacy=privacy, *args, **kwargs) params = {
'method': self.method,
}
# Extend our parameters
params.update(self.url_parameters(privacy=privacy, *args, **kwargs))
# Append our headers into our parameters # Append our headers into our parameters
params.update({'+{}'.format(k): v for k, v in self.headers.items()}) params.update({'+{}'.format(k): v for k, v in self.headers.items()})
@ -219,16 +253,15 @@ class NotifyAppriseAPI(NotifyBase):
# Prepare HTTP Headers # Prepare HTTP Headers
headers = { headers = {
'User-Agent': self.app_id, 'User-Agent': self.app_id,
'Content-Type': 'application/json'
} }
# Apply any/all header over-rides defined # Apply any/all header over-rides defined
headers.update(self.headers) headers.update(self.headers)
# Track our potential attachments
attachments = [] attachments = []
files = []
if attach: if attach:
for attachment in attach: for no, attachment in enumerate(attach, start=1):
# Perform some simple error checking # Perform some simple error checking
if not attachment: if not attachment:
# We could not access the attachment # We could not access the attachment
@ -238,15 +271,26 @@ class NotifyAppriseAPI(NotifyBase):
return False return False
try: try:
with open(attachment.path, 'rb') as f: if self.method == AppriseAPIMethod.JSON:
# Output must be in a DataURL format (that's what with open(attachment.path, 'rb') as f:
# PushSafer calls it): # Output must be in a DataURL format (that's what
attachments.append({ # PushSafer calls it):
'filename': attachment.name, attachments.append({
'base64': base64.b64encode(f.read()) 'filename': attachment.name,
.decode('utf-8'), 'base64': base64.b64encode(f.read())
'mimetype': attachment.mimetype, .decode('utf-8'),
}) 'mimetype': attachment.mimetype,
})
else: # AppriseAPIMethod.FORM
files.append((
'file{:02d}'.format(no),
(
attachment.name,
open(attachment.path, 'rb'),
attachment.mimetype,
)
))
except (OSError, IOError) as e: except (OSError, IOError) as e:
self.logger.warning( self.logger.warning(
@ -262,9 +306,13 @@ class NotifyAppriseAPI(NotifyBase):
'body': body, 'body': body,
'type': notify_type, 'type': notify_type,
'format': self.notify_format, 'format': self.notify_format,
'attachments': attachments,
} }
if self.method == AppriseAPIMethod.JSON:
headers['Content-Type'] = 'application/json'
payload['attachments'] = attachments
payload = dumps(payload)
if self.__tags: if self.__tags:
payload['tag'] = self.__tags payload['tag'] = self.__tags
@ -285,8 +333,8 @@ class NotifyAppriseAPI(NotifyBase):
# Some entries can not be over-ridden # Some entries can not be over-ridden
headers.update({ headers.update({
'User-Agent': self.app_id, # Our response to be in JSON format always
'Content-Type': 'application/json', 'Accept': 'application/json',
# Pass our Source UUID4 Identifier # Pass our Source UUID4 Identifier
'X-Apprise-ID': self.asset._uid, 'X-Apprise-ID': self.asset._uid,
# Pass our current recursion count to our upstream server # Pass our current recursion count to our upstream server
@ -304,9 +352,10 @@ class NotifyAppriseAPI(NotifyBase):
try: try:
r = requests.post( r = requests.post(
url, url,
data=dumps(payload), data=payload,
headers=headers, headers=headers,
auth=auth, auth=auth,
files=files if files else None,
verify=self.verify_certificate, verify=self.verify_certificate,
timeout=self.request_timeout, timeout=self.request_timeout,
) )
@ -328,7 +377,8 @@ class NotifyAppriseAPI(NotifyBase):
return False return False
else: else:
self.logger.info('Sent Apprise API notification.') self.logger.info(
'Sent Apprise API notification; method=%s.', self.method)
except requests.RequestException as e: except requests.RequestException as e:
self.logger.warning( self.logger.warning(
@ -339,6 +389,18 @@ class NotifyAppriseAPI(NotifyBase):
# Return; we're done # Return; we're done
return False return False
except (OSError, IOError) as e:
self.logger.warning(
'An I/O error occurred while reading one of the '
'attached files.')
self.logger.debug('I/O Exception: %s' % str(e))
return False
finally:
for file in files:
# Ensure all files are closed
file[1][1].close()
return True return True
@staticmethod @staticmethod
@ -415,4 +477,9 @@ class NotifyAppriseAPI(NotifyBase):
# re-assemble our full path # re-assemble our full path
results['fullpath'] = '/'.join(entries) results['fullpath'] = '/'.join(entries)
# Set method if specified
if 'method' in results['qsd'] and len(results['qsd']['method']):
results['method'] = \
NotifyAppriseAPI.unquote(results['qsd']['method'])
return results return results

View File

@ -142,6 +142,20 @@ apprise_url_tests = (
# Our expected url(privacy=True) startswith() response: # Our expected url(privacy=True) startswith() response:
'privacy_url': 'apprises://localhost:8080/m...4/', 'privacy_url': 'apprises://localhost:8080/m...4/',
}), }),
('apprises://localhost:8080/abc123/?method=json', {
'instance': NotifyAppriseAPI,
# Our expected url(privacy=True) startswith() response:
'privacy_url': 'apprises://localhost:8080/a...3/',
}),
('apprises://localhost:8080/abc123/?method=form', {
'instance': NotifyAppriseAPI,
# Our expected url(privacy=True) startswith() response:
'privacy_url': 'apprises://localhost:8080/a...3/',
}),
# Invalid method specified
('apprises://localhost:8080/abc123/?method=invalid', {
'instance': TypeError,
}),
('apprises://user:password@localhost:8080/mytoken5/', { ('apprises://user:password@localhost:8080/mytoken5/', {
'instance': NotifyAppriseAPI, 'instance': NotifyAppriseAPI,
@ -190,52 +204,65 @@ def test_notify_apprise_api_attachments(mock_post):
""" """
okay_response = requests.Request() okay_response = requests.Request()
okay_response.status_code = requests.codes.ok
okay_response.content = ""
# Assign our mock object our return value for method in ('json', 'form'):
mock_post.return_value = okay_response okay_response.status_code = requests.codes.ok
okay_response.content = ""
obj = Apprise.instantiate('apprise://user@localhost/mytoken1/') # Assign our mock object our return value
assert isinstance(obj, NotifyAppriseAPI) mock_post.return_value = okay_response
# Test Valid Attachment obj = Apprise.instantiate(
path = os.path.join(TEST_VAR_DIR, 'apprise-test.gif') 'apprise://user@localhost/mytoken1/?method={}'.format(method))
attach = AppriseAttachment(path) assert isinstance(obj, NotifyAppriseAPI)
assert obj.notify(
body='body', title='title', notify_type=NotifyType.INFO,
attach=attach) is True
# Test invalid attachment # Test Valid Attachment
path = os.path.join(TEST_VAR_DIR, '/invalid/path/to/an/invalid/file.jpg') path = os.path.join(TEST_VAR_DIR, 'apprise-test.gif')
assert obj.notify( attach = AppriseAttachment(path)
body='body', title='title', notify_type=NotifyType.INFO,
attach=path) is False
# 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('builtins.open', side_effect=OSError()):
# We can't send the message we can't open the attachment for reading
assert obj.notify( assert obj.notify(
body='body', title='title', notify_type=NotifyType.INFO, body='body', title='title', notify_type=NotifyType.INFO,
attach=attach) is False attach=attach) is True
# test the handling of our batch modes # Test invalid attachment
obj = Apprise.instantiate('apprise://user@localhost/mytoken1/') path = os.path.join(
assert isinstance(obj, NotifyAppriseAPI) 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
# Now send an attachment normally without issues # Test Valid Attachment (load 3)
mock_post.reset_mock() path = (
assert obj.notify( os.path.join(TEST_VAR_DIR, 'apprise-test.gif'),
body='body', title='title', notify_type=NotifyType.INFO, os.path.join(TEST_VAR_DIR, 'apprise-test.gif'),
attach=attach) is True os.path.join(TEST_VAR_DIR, 'apprise-test.gif'),
assert mock_post.call_count == 1 )
attach = AppriseAttachment(path)
# Return our good configuration
mock_post.side_effect = None
mock_post.return_value = okay_response
with mock.patch('builtins.open', 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
with mock.patch('requests.post', side_effect=OSError()):
# Attachment issue
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('apprise://user@localhost/mytoken1/')
assert isinstance(obj, NotifyAppriseAPI)
# 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
mock_post.reset_mock()