From 3f763f894d6bfb081fd334e0fa5faa73308b006b Mon Sep 17 00:00:00 2001 From: Chris Caron Date: Thu, 26 Dec 2019 23:51:49 -0500 Subject: [PATCH] Pushover Attachment Support --- apprise/plugins/NotifyPushover.py | 210 ++++++++++++++++++++++-------- test/test_pushover.py | 107 +++++++++++++++ 2 files changed, 263 insertions(+), 54 deletions(-) create mode 100644 test/test_pushover.py diff --git a/apprise/plugins/NotifyPushover.py b/apprise/plugins/NotifyPushover.py index 58fb63cb..0d7c46db 100644 --- a/apprise/plugins/NotifyPushover.py +++ b/apprise/plugins/NotifyPushover.py @@ -32,6 +32,7 @@ from ..common import NotifyType from ..utils import parse_list from ..utils import validate_regex from ..AppriseLocale import gettext_lazy as _ +from ..attachment.AttachBase import AttachBase # Flag used as a placeholder to sending to all devices PUSHOVER_SEND_TO_ALL = 'ALL_DEVICES' @@ -140,6 +141,14 @@ class NotifyPushover(NotifyBase): # Default Pushover sound default_pushover_sound = PushoverSound.PUSHOVER + # 2.5MB is the maximum supported image filesize as per documentation + # here: https://pushover.net/api#attachments (Dec 26th, 2019) + attach_max_size_bytes = 2621440 + + # The regular expression of the current attachment supported mime types + # At this time it is only images + attach_supported_mime_type = r'^image/.*' + # Define object templates templates = ( '{schema}://{user_key}@{token}', @@ -281,17 +290,12 @@ class NotifyPushover(NotifyBase): raise TypeError(msg) return - def send(self, body, title='', notify_type=NotifyType.INFO, **kwargs): + def send(self, body, title='', notify_type=NotifyType.INFO, attach=None, + **kwargs): """ Perform Pushover Notification """ - headers = { - 'User-Agent': self.app_id, - 'Content-Type': 'application/x-www-form-urlencoded' - } - auth = (self.token, '') - # error tracking (used for function return) has_error = False @@ -314,7 +318,7 @@ class NotifyPushover(NotifyBase): 'token': self.token, 'user': self.user_key, 'priority': str(self.priority), - 'title': title, + 'title': title if title else self.app_desc, 'message': body, 'device': device, 'sound': self.sound, @@ -323,60 +327,158 @@ class NotifyPushover(NotifyBase): if self.priority == PushoverPriority.EMERGENCY: payload.update({'retry': self.retry, 'expire': self.expire}) - self.logger.debug('Pushover POST URL: %s (cert_verify=%r)' % ( - self.notify_url, self.verify_certificate, - )) - self.logger.debug('Pushover Payload: %s' % str(payload)) + if attach: + # Create a copy of our payload + _payload = payload.copy() - # Always call throttle before any remote server i/o is made - self.throttle() + # Send with attachments + for attachment in attach: + # Simple send + if not self._send(_payload, attachment): + # Mark our failure + has_error = True + # clean exit from our attachment loop + break - try: - r = requests.post( - self.notify_url, - data=payload, - headers=headers, - auth=auth, - verify=self.verify_certificate, - ) - if r.status_code != requests.codes.ok: - # We had a problem - status_str = \ - NotifyPushover.http_response_code_lookup( - r.status_code, PUSHOVER_HTTP_ERROR_MAP) - - self.logger.warning( - 'Failed to send Pushover notification to {}: ' - '{}{}error={}.'.format( - device, - status_str, - ', ' if status_str else '', - r.status_code)) - - self.logger.debug( - 'Response Details:\r\n{}'.format(r.content)) + # To handle multiple attachments, clean up our message + _payload['title'] = '...' + _payload['message'] = attachment.name + # No need to alarm for each consecutive attachment uploaded + # afterwards + _payload['sound'] = PushoverSound.NONE + else: + # Simple send + if not self._send(payload): # Mark our failure has_error = True - continue - - else: - self.logger.info( - 'Sent Pushover notification to %s.' % device) - - except requests.RequestException as e: - self.logger.warning( - 'A Connection error occured sending Pushover:%s ' % ( - device) + 'notification.' - ) - self.logger.debug('Socket Exception: %s' % str(e)) - - # Mark our failure - has_error = True - continue return not has_error + def _send(self, payload, attach=None): + """ + Wrapper to the requests (post) object + """ + + if isinstance(attach, AttachBase): + # Perform some simple error checking + if not attach: + # We could not access the attachment + self.logger.warning( + 'Could not access {}.'.format( + attach.url(privacy=True))) + return False + + # Perform some basic checks as we want to gracefully skip + # over unsupported mime types. + if not re.match( + self.attach_supported_mime_type, + attach.mimetype, + re.I): + # No problem; we just don't support this attachment + # type; gracefully move along + self.logger.debug( + 'Ignored unsupported Pushover attachment ({}): {}' + .format( + attach.mimetype, + attach.url(privacy=True))) + + return True + + # If we get here, we're dealing with a supported image. + # Verify that the filesize is okay though. + file_size = len(attach) + if not (file_size > 0 + and file_size <= self.attach_max_size_bytes): + + # File size is no good + self.logger.warning( + 'Pushover attachment size ({}B) exceeds limit: {}' + .format(file_size, attach.url(privacy=True))) + + return False + + # Default Header + headers = { + 'User-Agent': self.app_id, + } + + # Authentication + auth = (self.token, '') + + # Some default values for our request object to which we'll update + # depending on what our payload is + files = None + + self.logger.debug('Pushover POST URL: %s (cert_verify=%r)' % ( + self.notify_url, self.verify_certificate, + )) + self.logger.debug('Pushover Payload: %s' % str(payload)) + + # Always call throttle before any remote server i/o is made + self.throttle() + + try: + # Open our attachment path if required: + if attach: + files = {'attachment': (attach.name, open(attach.path, 'rb'))} + + r = requests.post( + self.notify_url, + data=payload, + headers=headers, + files=files, + auth=auth, + verify=self.verify_certificate, + ) + + if r.status_code != requests.codes.ok: + # We had a problem + status_str = \ + NotifyPushover.http_response_code_lookup( + r.status_code, PUSHOVER_HTTP_ERROR_MAP) + + self.logger.warning( + 'Failed to send Pushover notification to {}: ' + '{}{}error={}.'.format( + payload['device'], + status_str, + ', ' if status_str else '', + r.status_code)) + + self.logger.debug( + 'Response Details:\r\n{}'.format(r.content)) + + return False + + else: + self.logger.info( + 'Sent Pushover notification to %s.' % payload['device']) + + except requests.RequestException as e: + self.logger.warning( + 'A Connection error occured sending Pushover:%s ' % ( + payload['device']) + 'notification.' + ) + self.logger.debug('Socket Exception: %s' % str(e)) + + return False + + except (OSError, IOError) as e: + self.logger.warning( + 'An I/O error occured while reading {}.'.format( + attach.name if attach else 'attachment')) + self.logger.debug('I/O Exception: %s' % str(e)) + return False + + finally: + # Close our file (if it's open) stored in the second element + # of our files tuple (index 1) + if files: + files['attachment'][1].close() + + return True + def url(self, privacy=False, *args, **kwargs): """ Returns the URL built dynamically based on specified arguments. diff --git a/test/test_pushover.py b/test/test_pushover.py new file mode 100644 index 00000000..6924b5c0 --- /dev/null +++ b/test/test_pushover.py @@ -0,0 +1,107 @@ +# -*- coding: utf-8 -*- +# +# 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 mock +import requests +from json import dumps +from apprise import Apprise +from apprise import AppriseAttachment +from apprise import plugins + +# 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_pushover_attachments(mock_post, tmpdir): + """ + API: NotifyPushover() Attachment Checks + + """ + # Disable Throttling to speed testing + plugins.NotifyBase.request_rate_per_sec = 0 + + # Initialize some generic (but valid) tokens + user_key = 'u' * 30 + api_token = 'a' * 30 + + # Prepare Mock return object + response = mock.Mock() + response.content = dumps( + {"status": 1, "request": "647d2300-702c-4b38-8b2f-d56326ae460b"}) + response.status_code = requests.codes.ok + mock_post.return_value = response + + # prepare our attachment + attach = AppriseAttachment(os.path.join(TEST_VAR_DIR, 'apprise-test.gif')) + + # Instantiate our object + obj = Apprise.instantiate( + 'pover://{}@{}/'.format(user_key, api_token)) + assert isinstance(obj, plugins.NotifyPushover) + + # Test our attachment + assert obj.notify(body="test", attach=attach) is True + + # Test multiple attachments + assert attach.add(os.path.join(TEST_VAR_DIR, 'apprise-test.gif')) + assert obj.notify(body="test", attach=attach) is True + + image = tmpdir.mkdir("pover_image").join("test.jpg") + image.write('a' * plugins.NotifyPushover.attach_max_size_bytes) + + attach = AppriseAttachment.instantiate(str(image)) + assert obj.notify(body="test", attach=attach) is True + + # Add 1 more byte to the file (putting it over the limit) + image.write('a' * (plugins.NotifyPushover.attach_max_size_bytes + 1)) + + attach = AppriseAttachment.instantiate(str(image)) + assert obj.notify(body="test", attach=attach) is False + + # Test case when file is missing + attach = AppriseAttachment.instantiate( + 'file://{}?cache=False'.format(str(image))) + os.unlink(str(image)) + assert obj.notify( + body='body', title='title', attach=attach) is False + + # Test unsuported files: + image = tmpdir.mkdir("pover_unsupported").join("test.doc") + image.write('a' * 256) + attach = AppriseAttachment.instantiate(str(image)) + + # Content is silently ignored + assert obj.notify(body="test", attach=attach) is True + + # Throw an exception on the second call to requests.post() + mock_post.side_effect = OSError() + # prepare our attachment + attach = AppriseAttachment(os.path.join(TEST_VAR_DIR, 'apprise-test.gif')) + assert obj.notify(body="test", attach=attach) is False