PushBullet Attachment Support (#176)

pull/177/head
Chris Caron 2019-11-21 22:12:47 -05:00 committed by GitHub
parent f4a7df0520
commit 3b19f43244
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
5 changed files with 386 additions and 82 deletions

View File

@ -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):
"""

View File

@ -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.

View File

@ -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)

101
test/test_pushbullet.py Normal file
View File

@ -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

View File

@ -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