diff --git a/apprise/URLBase.py b/apprise/URLBase.py index b919ea29..4d62b82c 100644 --- a/apprise/URLBase.py +++ b/apprise/URLBase.py @@ -219,6 +219,12 @@ class URLBase(object): # return any match return tags in self.tags + def __str__(self): + """ + Returns the url path + """ + return self.url(privacy=True) + @staticmethod def escape_html(html, convert_new_lines=False, whitespace=True): """ diff --git a/apprise/plugins/NotifyPushBullet.py b/apprise/plugins/NotifyPushBullet.py index 05defa00..42b98174 100644 --- a/apprise/plugins/NotifyPushBullet.py +++ b/apprise/plugins/NotifyPushBullet.py @@ -25,6 +25,7 @@ import requests from json import dumps +from json import loads from .NotifyBase import NotifyBase from ..utils import GET_EMAIL_RE @@ -32,6 +33,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 PUSHBULLET_SEND_TO_ALL = 'ALL_DEVICES' @@ -56,11 +58,15 @@ class NotifyPushBullet(NotifyBase): # The default secure protocol secure_protocol = 'pbul' + # Allow 50 requests per minute (Tier 2). + # 60/50 = 0.2 + request_rate_per_sec = 1.2 + # A URL that takes you to the setup/help of the specific protocol setup_url = 'https://github.com/caronc/apprise/wiki/Notify_pushbullet' # PushBullet uses the http protocol with JSON requests - notify_url = 'https://api.pushbullet.com/v2/pushes' + notify_url = 'https://api.pushbullet.com/v2/{}' # Define object templates templates = ( @@ -125,26 +131,86 @@ class NotifyPushBullet(NotifyBase): return - def send(self, body, title='', notify_type=NotifyType.INFO, **kwargs): + def send(self, body, title='', notify_type=NotifyType.INFO, attach=None, + **kwargs): """ Perform PushBullet Notification """ - headers = { - 'User-Agent': self.app_id, - 'Content-Type': 'application/json' - } - auth = (self.accesstoken, '') - # error tracking (used for function return) has_error = False + # Build a list of our attachments + attachments = [] + + if attach: + # We need to upload our payload first so that we can source it + # in remaining messages + for attachment in attach: + # prepare payload + payload = { + 'file_name': attachment.name, + 'file_type': attachment.mimetype, + } + # First thing we need to do is make a request so that we can + # get a URL to post our request to. + # see: https://docs.pushbullet.com/#upload-request + okay, response = self._send( + self.notify_url.format('upload-request'), payload) + if not okay: + # We can't post our attachment + return False + + # If we get here, our output will look something like this: + # { + # "file_name": "cat.jpg", + # "file_type": "image/jpeg", + # "file_url": "https://dl.pushb.com/abc/cat.jpg", + # "upload_url": "https://upload.pushbullet.com/abcd123" + # } + + # - The file_url is where the file will be available after it + # is uploaded. + # - The upload_url is where to POST the file to. The file must + # be posted using multipart/form-data encoding. + + # Prepare our attachment payload; we'll use this if we + # successfully upload the content below for later on. + try: + # By placing this in a try/except block we can validate + # our response at the same time as preparing our payload + payload = { + # PushBullet v2/pushes file type: + 'type': 'file', + 'file_name': response['file_name'], + 'file_type': response['file_type'], + 'file_url': response['file_url'], + } + + if response['file_type'].startswith('image/'): + # Allow image to be displayed inline (if image type) + payload['image_url'] = response['file_url'] + + upload_url = response['upload_url'] + + except (KeyError, TypeError): + # A method of verifying our content exists + return False + + okay, response = self._send(upload_url, attachment) + if not okay: + # We can't post our attachment + return False + + # Save our pre-prepared payload for attachment posting + attachments.append(payload) + # Create a copy of the targets list targets = list(self.targets) while len(targets): recipient = targets.pop(0) - # prepare JSON Object + # prepare payload payload = { 'type': 'note', 'title': title, @@ -166,64 +232,128 @@ class NotifyPushBullet(NotifyBase): else: payload['device_iden'] = recipient - self.logger.debug( - "Recipient '%s' is a device" % recipient) + self.logger.debug("Recipient '%s' is a device" % recipient) - self.logger.debug('PushBullet POST URL: %s (cert_verify=%r)' % ( - self.notify_url, self.verify_certificate, - )) - self.logger.debug('PushBullet Payload: %s' % str(payload)) - - # Always call throttle before any remote server i/o is made - self.throttle() - - try: - r = requests.post( - self.notify_url, - data=dumps(payload), - headers=headers, - auth=auth, - verify=self.verify_certificate, - ) - - if r.status_code != requests.codes.ok: - # We had a problem - status_str = \ - NotifyPushBullet.http_response_code_lookup( - r.status_code, PUSHBULLET_HTTP_ERROR_MAP) - - self.logger.warning( - 'Failed to send PushBullet notification to {}:' - '{}{}error={}.'.format( - recipient, - status_str, - ', ' if status_str else '', - r.status_code)) - - self.logger.debug( - 'Response Details:\r\n{}'.format(r.content)) - - # Mark our failure - has_error = True - continue - - else: - self.logger.info( - 'Sent PushBullet notification to "%s".' % (recipient)) - - except requests.RequestException as e: - self.logger.warning( - 'A Connection error occured sending PushBullet ' - 'notification to "%s".' % (recipient), - ) - self.logger.debug('Socket Exception: %s' % str(e)) - - # Mark our failure + okay, response = self._send( + self.notify_url.format('pushes'), payload) + if not okay: has_error = True continue + self.logger.info( + 'Sent PushBullet notification to "%s".' % (recipient)) + + for attach_payload in attachments: + # Send our attachments to our same user (already prepared as + # our payload object) + okay, response = self._send( + self.notify_url.format('pushes'), attach_payload) + if not okay: + has_error = True + continue + + self.logger.info( + 'Sent PushBullet attachment (%s) to "%s".' % ( + attach_payload['file_name'], recipient)) + return not has_error + def _send(self, url, payload, **kwargs): + """ + Wrapper to the requests (post) object + """ + + headers = { + 'User-Agent': self.app_id, + } + + # Some default values for our request object to which we'll update + # depending on what our payload is + files = None + data = None + + if not isinstance(payload, AttachBase): + # Send our payload as a JSON object + headers['Content-Type'] = 'application/json' + data = dumps(payload) if payload else None + + auth = (self.accesstoken, '') + + self.logger.debug('PushBullet POST URL: %s (cert_verify=%r)' % ( + url, self.verify_certificate, + )) + self.logger.debug('PushBullet Payload: %s' % str(payload)) + + # Always call throttle before any remote server i/o is made + self.throttle() + + # Default response type + response = None + + try: + # Open our attachment path if required: + if isinstance(payload, AttachBase): + files = {'file': (payload.name, open(payload.path, 'rb'))} + + r = requests.post( + url, + data=data, + headers=headers, + files=files, + auth=auth, + verify=self.verify_certificate, + ) + + try: + response = loads(r.content) + + except (TypeError, AttributeError, ValueError): + # AttributeError means r.content was None + response = r.content + pass + + if r.status_code not in ( + requests.codes.ok, requests.codes.no_content): + # We had a problem + status_str = \ + NotifyPushBullet.http_response_code_lookup( + r.status_code, PUSHBULLET_HTTP_ERROR_MAP) + + self.logger.warning( + 'Failed to deliver payload to PushBullet:' + '{}{}error={}.'.format( + status_str, + ', ' if status_str else '', + r.status_code)) + + self.logger.debug( + 'Response Details:\r\n{}'.format(r.content)) + + return False, response + + # otherwise we were successful + return True, response + + except requests.RequestException as e: + self.logger.warning( + 'A Connection error occured communicating with PushBullet.') + self.logger.debug('Socket Exception: %s' % str(e)) + + return False, response + + except (OSError, IOError) as e: + self.logger.warning( + 'An I/O error occured while reading {}.'.format( + payload.name if payload else 'attachment')) + self.logger.debug('I/O Exception: %s' % str(e)) + return False, response + + finally: + # Close our file (if it's open) stored in the second element + # of our files tuple (index 1) + if files: + files['file'][1].close() + def url(self, privacy=False, *args, **kwargs): """ Returns the URL built dynamically based on specified arguments. diff --git a/test/test_attach_base.py b/test/test_attach_base.py index 9eb1abee..a2281f49 100644 --- a/test/test_attach_base.py +++ b/test/test_attach_base.py @@ -63,6 +63,10 @@ def test_attach_base(): # Create an object with no mimetype over-ride obj = AttachBase() + # Get our string object + with pytest.raises(NotImplementedError): + str(obj) + # We can not process name/path/mimetype at a Base level with pytest.raises(NotImplementedError): obj.download() @@ -90,3 +94,5 @@ def test_attach_base(): assert isinstance(results, dict) # mime defined assert results.get('mimetype') == 'image/jpeg' + # We can retrieve our url + assert str(results) diff --git a/test/test_pushbullet.py b/test/test_pushbullet.py new file mode 100644 index 00000000..67b9147d --- /dev/null +++ b/test/test_pushbullet.py @@ -0,0 +1,101 @@ +# -*- 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_pushbullet_attachments(mock_post): + """ + API: NotifyPushBullet() Attachment Checks + + """ + # Disable Throttling to speed testing + plugins.NotifyBase.request_rate_per_sec = 0 + + # Initialize some generic (but valid) tokens + access_token = 't' * 32 + + # Prepare Mock return object + response = mock.Mock() + response.content = dumps({ + "file_name": "cat.jpg", + "file_type": "image/jpeg", + "file_url": "https://dl.pushb.com/abc/cat.jpg", + "upload_url": "https://upload.pushbullet.com/abcd123", + }) + response.status_code = requests.codes.ok + + # prepare our attachment + attach = AppriseAttachment(os.path.join(TEST_VAR_DIR, 'apprise-test.gif')) + + # Test our markdown + obj = Apprise.instantiate( + 'pbul://{}/?format=markdown'.format(access_token)) + + # Throw an exception on the first call to requests.post() + mock_post.side_effect = requests.RequestException() + + # We'll fail now because of an internal exception + assert obj.send(body="test", attach=attach) is False + + # Throw an exception on the second call to requests.post() + mock_post.side_effect = [response, OSError()] + + # We'll fail now because of an internal exception + assert obj.send(body="test", attach=attach) is False + + # Throw an exception on the third call to requests.post() + mock_post.side_effect = [ + response, response, requests.RequestException()] + + # We'll fail now because of an internal exception + assert obj.send(body="test", attach=attach) is False + + # Throw an exception on the forth call to requests.post() + mock_post.side_effect = [ + response, response, response, requests.RequestException()] + + # We'll fail now because of an internal exception + assert obj.send(body="test", attach=attach) is False + + # Test case where we don't get a valid response back + response.content = '}' + mock_post.side_effect = response + + # We'll fail because of an invalid json object + assert obj.send(body="test", attach=attach) is False diff --git a/test/test_rest_plugins.py b/test/test_rest_plugins.py index 410d7cc0..a5a45fdd 100644 --- a/test/test_rest_plugins.py +++ b/test/test_rest_plugins.py @@ -1675,17 +1675,61 @@ TEST_URLS = ( ('pbul://', { 'instance': None, }), + ('pbul://:@/', { + 'instance': None, + }), # APIkey ('pbul://%s' % ('a' * 32), { 'instance': plugins.NotifyPushBullet, + 'check_attachments': False, + }), + # APIkey; but support attachment response + ('pbul://%s' % ('a' * 32), { + 'instance': plugins.NotifyPushBullet, + + # Test what happens if a batch send fails to return a messageCount + 'requests_response_text': { + 'file_name': 'cat.jpeg', + 'file_type': 'image/jpeg', + 'file_url': 'http://file_url', + 'upload_url': 'http://upload_url', + }, + }), + # APIkey; attachment testing that isn't an image type + ('pbul://%s' % ('a' * 32), { + 'instance': plugins.NotifyPushBullet, + + # Test what happens if a batch send fails to return a messageCount + 'requests_response_text': { + 'file_name': 'test.pdf', + 'file_type': 'application/pdf', + 'file_url': 'http://file_url', + 'upload_url': 'http://upload_url', + }, + }), + # APIkey; attachment testing were expected entry in payload is missing + ('pbul://%s' % ('a' * 32), { + 'instance': plugins.NotifyPushBullet, + + # Test what happens if a batch send fails to return a messageCount + 'requests_response_text': { + 'file_name': 'test.pdf', + 'file_type': 'application/pdf', + 'file_url': 'http://file_url', + # upload_url missing + }, + # Our Notification calls associated with attachments will fail: + 'attach_response': False, }), # API Key + channel ('pbul://%s/#channel/' % ('a' * 32), { 'instance': plugins.NotifyPushBullet, + 'check_attachments': False, }), # API Key + channel (via to= ('pbul://%s/?to=#channel' % ('a' * 32), { 'instance': plugins.NotifyPushBullet, + 'check_attachments': False, }), # API Key + 2 channels ('pbul://%s/#channel1/#channel2' % ('a' * 32), { @@ -1693,26 +1737,32 @@ TEST_URLS = ( # Our expected url(privacy=True) startswith() response: 'privacy_url': 'pbul://a...a/', + 'check_attachments': False, }), # API Key + device ('pbul://%s/device/' % ('a' * 32), { 'instance': plugins.NotifyPushBullet, + 'check_attachments': False, }), # API Key + 2 devices ('pbul://%s/device1/device2/' % ('a' * 32), { 'instance': plugins.NotifyPushBullet, + 'check_attachments': False, }), # API Key + email ('pbul://%s/user@example.com/' % ('a' * 32), { 'instance': plugins.NotifyPushBullet, + 'check_attachments': False, }), # API Key + 2 emails ('pbul://%s/user@example.com/abc@def.com/' % ('a' * 32), { 'instance': plugins.NotifyPushBullet, + 'check_attachments': False, }), # API Key + Combo ('pbul://%s/device/#channel/user@example.com/' % ('a' * 32), { 'instance': plugins.NotifyPushBullet, + 'check_attachments': False, }), # , ('pbul://%s' % ('a' * 32), { @@ -1720,39 +1770,42 @@ TEST_URLS = ( # force a failure 'response': False, 'requests_response_code': requests.codes.internal_server_error, + 'check_attachments': False, }), ('pbul://%s' % ('a' * 32), { 'instance': plugins.NotifyPushBullet, # throw a bizzare code forcing us to fail to look it up 'response': False, 'requests_response_code': 999, + 'check_attachments': False, }), ('pbul://%s' % ('a' * 32), { 'instance': plugins.NotifyPushBullet, # Throws a series of connection and transfer exceptions when this flag # is set and tests that we gracfully handle them 'test_requests_exceptions': True, - }), - ('pbul://:@/', { - 'instance': None, + 'check_attachments': False, }), ('pbul://%s' % ('a' * 32), { 'instance': plugins.NotifyPushBullet, # force a failure 'response': False, 'requests_response_code': requests.codes.internal_server_error, + 'check_attachments': False, }), ('pbul://%s' % ('a' * 32), { 'instance': plugins.NotifyPushBullet, # throw a bizzare code forcing us to fail to look it up 'response': False, 'requests_response_code': 999, + 'check_attachments': False, }), ('pbul://%s' % ('a' * 32), { 'instance': plugins.NotifyPushBullet, # Throws a series of connection and transfer exceptions when this flag # is set and tests that we gracfully handle them 'test_requests_exceptions': True, + 'check_attachments': False, }), ################################## @@ -3537,10 +3590,17 @@ def test_rest_plugins(mock_post, mock_get): # Our expected Notify response (True or False) notify_response = meta.get('notify_response', response) + # Our expected Notify Attachment response (True or False) + attach_response = meta.get('attach_response', notify_response) + # Our expected privacy url # Don't set this if don't need to check it's value privacy_url = meta.get('privacy_url') + # Test attachments + # Don't set this if don't need to check it's value + check_attachments = meta.get('check_attachments', True) + # Allow us to force the server response code to be something other then # the defaults requests_response_code = meta.get( @@ -3686,25 +3746,26 @@ def test_rest_plugins(mock_post, mock_get): notify_type=notify_type, overflow=OverflowMode.SPLIT) == notify_response - # Test single attachment support; even if the service - # doesn't support attachments, it should still gracefully - # ignore the data - attach = os.path.join(TEST_VAR_DIR, 'apprise-test.gif') - assert obj.notify( - body=body, title=title, - notify_type=notify_type, - attach=attach) == notify_response + if check_attachments: + # Test single attachment support; even if the service + # doesn't support attachments, it should still + # gracefully ignore the data + attach = os.path.join(TEST_VAR_DIR, 'apprise-test.gif') + assert obj.notify( + body=body, title=title, + notify_type=notify_type, + attach=attach) == attach_response - # Same results should apply to a list of attachments - attach = AppriseAttachment(( - os.path.join(TEST_VAR_DIR, 'apprise-test.gif'), - os.path.join(TEST_VAR_DIR, 'apprise-test.png'), - os.path.join(TEST_VAR_DIR, 'apprise-test.jpeg'), - )) - assert obj.notify( - body=body, title=title, - notify_type=notify_type, - attach=attach) == notify_response + # Same results should apply to a list of attachments + attach = AppriseAttachment(( + os.path.join(TEST_VAR_DIR, 'apprise-test.gif'), + os.path.join(TEST_VAR_DIR, 'apprise-test.png'), + os.path.join(TEST_VAR_DIR, 'apprise-test.jpeg'), + )) + assert obj.notify( + body=body, title=title, + notify_type=notify_type, + attach=attach) == attach_response else: # Disable throttling