diff --git a/README.md b/README.md index fcdf1e35..c74feb84 100644 --- a/README.md +++ b/README.md @@ -45,7 +45,8 @@ The table below identifies the services this tool supports and some example serv | [Prowl](https://github.com/caronc/apprise/wiki/Notify_prowl) | prowl:// | (TCP) 443 | prowl://apikey
prowl://apikey/providerkey | [Pushalot](https://github.com/caronc/apprise/wiki/Notify_pushalot) | palot:// | (TCP) 443 | palot://authorizationtoken | [PushBullet](https://github.com/caronc/apprise/wiki/Notify_pushbullet) | pbul:// | (TCP) 443 | pbul://accesstoken
pbul://accesstoken/#channel
pbul://accesstoken/A_DEVICE_ID
pbul://accesstoken/email@address.com
pbul://accesstoken/#channel/#channel2/email@address.net/DEVICE -| [Pushjet](https://github.com/caronc/apprise/wiki/Notify_pushjet) | pjet:// | (TCP) 80 | pjet://secret
pjet://secret@hostname
pjet://secret@hostname:port
pjets://secret@hostname
pjets://secret@hostname:port
Note: if no hostname defined https://api.pushjet.io will be used +| [Pushjet](https://github.com/caronc/apprise/wiki/Notify_pushjet) | pjet:// | (TCP) 80 | pjet://secret@hostname
pjet://secret@hostname:port
pjets://secret@hostname
pjets://secret@hostname:port +| [Pushed](https://github.com/caronc/apprise/wiki/Notify_pushed) | pushed:// | (TCP) 443 | pushed://appkey/appsecret/
pushed://appkey/appsecret/#ChannelAlias
pushed://appkey/appsecret/#ChannelAlias1/#ChannelAlias2/#ChannelAliasN
pushed://appkey/appsecret/@UserPushedID
pushed://appkey/appsecret/@UserPushedID1/@UserPushedID2/@UserPushedIDN | [Pushover](https://github.com/caronc/apprise/wiki/Notify_pushover) | pover:// | (TCP) 443 | pover://user@token
pover://user@token/DEVICE
pover://user@token/DEVICE1/DEVICE2/DEVICEN
_Note: you must specify both your user_id and token_ | [Rocket.Chat](https://github.com/caronc/apprise/wiki/Notify_rocketchat) | rocket:// or rockets:// | (TCP) 80 or 443 | rocket://user:password@hostname/RoomID/Channel
rockets://user:password@hostname:443/Channel1/Channel1/RoomID
rocket://user:password@hostname/Channel | [Slack](https://github.com/caronc/apprise/wiki/Notify_slack) | slack:// | (TCP) 443 | slack://TokenA/TokenB/TokenC/Channel
slack://botname@TokenA/TokenB/TokenC/Channel
slack://user@TokenA/TokenB/TokenC/Channel1/Channel2/ChannelN diff --git a/apprise/plugins/NotifyPushed.py b/apprise/plugins/NotifyPushed.py new file mode 100644 index 00000000..b925ba63 --- /dev/null +++ b/apprise/plugins/NotifyPushed.py @@ -0,0 +1,301 @@ +# -*- coding: utf-8 -*- +# +# Copyright (C) 2019 Chris Caron +# 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 re +import requests +from json import dumps + +from .NotifyBase import NotifyBase +from .NotifyBase import HTTP_ERROR_MAP +from ..utils import compat_is_basestring + +# Used to detect and parse channels +IS_CHANNEL = re.compile(r'^#(?P[A-Za-z0-9]+)$') + +# Used to detect and parse a users push id +IS_USER_PUSHED_ID = re.compile(r'^@(?P[A-Za-z0-9]+)$') + +# Used to break apart list of potential tags by their delimiter +# into a usable list. +LIST_DELIM = re.compile(r'[ \t\r\n,\\/]+') + + +class NotifyPushed(NotifyBase): + """ + A wrapper to Pushed Notifications + + """ + + # The default descriptive name associated with the Notification + service_name = 'Pushed' + + # The services URL + service_url = 'https://pushed.co/' + + # The default secure protocol + secure_protocol = 'pushed' + + # A URL that takes you to the setup/help of the specific protocol + setup_url = 'https://github.com/caronc/apprise/wiki/Notify_pushed' + + # Pushed uses the http protocol with JSON requests + notify_url = 'https://api.pushed.co/1/push' + + # The maximum allowable characters allowed in the body per message + body_maxlen = 140 + + def __init__(self, app_key, app_secret, recipients=None, **kwargs): + """ + Initialize Pushed Object + + """ + super(NotifyPushed, self).__init__(**kwargs) + + if not app_key: + raise TypeError( + 'An invalid Application Key was specified.' + ) + + if not app_secret: + raise TypeError( + 'An invalid Application Secret was specified.' + ) + + # Initialize channel list + self.channels = list() + + # Initialize user list + self.users = list() + + if recipients is None: + recipients = [] + + elif compat_is_basestring(recipients): + recipients = [x for x in filter(bool, LIST_DELIM.split( + recipients, + ))] + + elif not isinstance(recipients, (set, tuple, list)): + raise TypeError( + 'An invalid receipient list was specified.' + ) + + # Validate recipients and drop bad ones: + for recipient in recipients: + result = IS_CHANNEL.match(recipient) + if result: + # store valid device + self.channels.append(result.group('name')) + continue + + result = IS_USER_PUSHED_ID.match(recipient) + if result: + # store valid room + self.users.append(result.group('name')) + continue + + self.logger.warning( + 'Dropped invalid channel/userid ' + '(%s) specified.' % recipient, + ) + + # Store our data + self.app_key = app_key + self.app_secret = app_secret + + return + + def notify(self, title, body, notify_type, **kwargs): + """ + Perform Pushed Notification + """ + + # Initiaize our error tracking + has_error = False + + # prepare JSON Object + payload = { + 'app_key': self.app_key, + 'app_secret': self.app_secret, + 'target_type': 'app', + 'content': body, + } + + # So the logic is as follows: + # - if no user/channel was specified, then we just simply notify the + # app. + # - if there are user/channels specified, then we only alert them + # while respecting throttle limits (in the event there are a lot of + # entries. + + if len(self.channels) + len(self.users) == 0: + # Just notify the app + return self.send_notification( + payload=payload, notify_type=notify_type, **kwargs) + + # If our code reaches here, we want to target channels and users (by + # their Pushed_ID instead... + + # Generate a copy of our original list + channels = list(self.channels) + users = list(self.users) + + # Copy our payload + _payload = dict(payload) + _payload['target_type'] = 'channel' + + while len(channels) > 0: + # Get Channel + _payload['target_alias'] = channels.pop(0) + + if not self.send_notification( + payload=_payload, notify_type=notify_type, **kwargs): + + # toggle flag + has_error = True + + if len(channels) + len(users) > 0: + # Prevent thrashing requests + self.throttle() + + # Copy our payload + _payload = dict(payload) + _payload['target_type'] = 'pushed_id' + + # Send all our defined User Pushed ID's + while len(users): + # Get User's Pushed ID + _payload['pushed_id'] = users.pop(0) + if not self.send_notification( + payload=_payload, notify_type=notify_type, **kwargs): + + # toggle flag + has_error = True + + if len(users) > 0: + # Prevent thrashing requests + self.throttle() + + return not has_error + + def send_notification(self, payload, notify_type, **kwargs): + """ + A lower level call that directly pushes a payload to the Pushed + Notification servers. This should never be called directly; it is + referenced automatically through the notify() function. + """ + + headers = { + 'User-Agent': self.app_id, + 'Content-Type': 'application/json' + } + + self.logger.debug('Pushed POST URL: %s (cert_verify=%r)' % ( + self.notify_url, self.verify_certificate, + )) + self.logger.debug('Pushed Payload: %s' % str(payload)) + try: + r = requests.post( + self.notify_url, + data=dumps(payload), + headers=headers, + verify=self.verify_certificate, + ) + + if r.status_code != requests.codes.ok: + # We had a problem + try: + self.logger.warning( + 'Failed to send Pushed notification: ' + '%s (error=%s).' % ( + HTTP_ERROR_MAP[r.status_code], + r.status_code)) + + except KeyError: + self.logger.warning( + 'Failed to send Pushed notification ' + '(error=%s).' % r.status_code) + + self.logger.debug('Response Details: %s' % r.raw.read()) + + # Return; we're done + return False + + else: + self.logger.info('Sent Pushed notification.') + + except requests.RequestException as e: + self.logger.warning( + 'A Connection error occured sending Pushed notification.') + self.logger.debug('Socket Exception: %s' % str(e)) + + # Return; we're done + return False + + return True + + @staticmethod + def parse_url(url): + """ + Parses the URL and returns enough arguments that can allow + us to substantiate this object. + + """ + results = NotifyBase.parse_url(url) + + if not results: + # We're done early as we couldn't load the results + return results + + # Apply our settings now + + # The first token is stored in the hostname + app_key = results['host'] + + # Initialize our recipients + recipients = None + + # Now fetch the remaining tokens + try: + app_secret = \ + [x for x in filter(bool, NotifyBase.split_path( + results['fullpath']))][0] + + except (ValueError, AttributeError, IndexError): + # Force some bad values that will get caught + # in parsing later + app_secret = None + app_key = None + + # Get our recipients + recipients = \ + [x for x in filter(bool, NotifyBase.split_path( + results['fullpath']))][1:] + + results['app_key'] = app_key + results['app_secret'] = app_secret + results['recipients'] = recipients + + return results diff --git a/apprise/plugins/NotifyPushjet/NotifyPushjet.py b/apprise/plugins/NotifyPushjet/NotifyPushjet.py index 7258b477..cb9ddc94 100644 --- a/apprise/plugins/NotifyPushjet/NotifyPushjet.py +++ b/apprise/plugins/NotifyPushjet/NotifyPushjet.py @@ -43,9 +43,6 @@ class NotifyPushjet(NotifyBase): # The default descriptive name associated with the Notification service_name = 'Pushjet' - # The services URL - service_url = 'https://pushjet.io/' - # The default protocol protocol = 'pjet' @@ -66,21 +63,16 @@ class NotifyPushjet(NotifyBase): Perform Pushjet Notification """ try: - if self.user and self.host: - server = "http://" - if self.secure: - server = "https://" - - server += self.host - if self.port: - server += ":" + str(self.port) + server = "http://" + if self.secure: + server = "https://" - api = pushjet.Api(server) - service = api.Service(secret_key=self.user) + server += self.host + if self.port: + server += ":" + str(self.port) - else: - api = pushjet.Api(pushjet.DEFAULT_API_URL) - service = api.Service(secret_key=self.host) + api = pushjet.Api(server) + service = api.Service(secret_key=self.user) service.send(body, title) self.logger.info('Sent Pushjet notification.') @@ -91,3 +83,28 @@ class NotifyPushjet(NotifyBase): return False return True + + @staticmethod + def parse_url(url): + """ + Parses the URL and returns enough arguments that can allow + us to substantiate this object. + + Syntax: + pjet://secret@hostname + pjet://secret@hostname:port + pjets://secret@hostname + pjets://secret@hostname:port + + """ + results = NotifyBase.parse_url(url) + + if not results: + # We're done early as we couldn't load the results + return results + + if not results.get('user'): + # a username is required + return None + + return results diff --git a/apprise/plugins/__init__.py b/apprise/plugins/__init__.py index 57ad71ca..74880fed 100644 --- a/apprise/plugins/__init__.py +++ b/apprise/plugins/__init__.py @@ -41,6 +41,7 @@ from .NotifyMatrix import NotifyMatrix from .NotifyMatterMost import NotifyMatterMost from .NotifyProwl import NotifyProwl from .NotifyPushalot import NotifyPushalot +from .NotifyPushed import NotifyPushed from .NotifyPushBullet import NotifyPushBullet from .NotifyPushjet.NotifyPushjet import NotifyPushjet from .NotifyPushover import NotifyPushover @@ -67,10 +68,10 @@ __all__ = [ 'NotifyBoxcar', 'NotifyEmail', 'NotifyEmby', 'NotifyDiscord', 'NotifyFaast', 'NotifyGnome', 'NotifyGrowl', 'NotifyIFTTT', 'NotifyJoin', 'NotifyJSON', 'NotifyMatrix', 'NotifyMatterMost', 'NotifyProwl', - 'NotifyPushalot', 'NotifyPushBullet', 'NotifyPushjet', 'NotifyPushover', - 'NotifyRocketChat', 'NotifySlack', 'NotifyStride', - 'NotifyTwitter', 'NotifyTelegram', 'NotifyXBMC', 'NotifyXML', - 'NotifyWindows', + 'NotifyPushalot', 'NotifyPushed', 'NotifyPushBullet', 'NotifyPushjet', + 'NotifyPushover', 'NotifyRocketChat', 'NotifySlack', 'NotifyStride', + 'NotifyTwitter', 'NotifyTelegram', 'NotifyXBMC', + 'NotifyXML', 'NotifyWindows', # Reference 'NotifyImageSize', 'NOTIFY_IMAGE_SIZES', 'NotifyType', 'NOTIFY_TYPES', diff --git a/test/test_pushjet_plugin.py b/test/test_pushjet_plugin.py index 1ab0f00b..789d4844 100644 --- a/test/test_pushjet_plugin.py +++ b/test/test_pushjet_plugin.py @@ -38,9 +38,9 @@ TEST_URLS = ( ('pjets://', { 'instance': None, }), - # Default query (uses pushjet server) + # You must specify a username ('pjet://%s' % ('a' * 32), { - 'instance': plugins.NotifyPushjet, + 'instance': None, }), # Specify your own server ('pjet://%s@localhost' % ('a' * 32), { diff --git a/test/test_rest_plugins.py b/test/test_rest_plugins.py index 1b0ee920..56c100d6 100644 --- a/test/test_rest_plugins.py +++ b/test/test_rest_plugins.py @@ -826,7 +826,25 @@ TEST_URLS = ( ('pbul://%s/device/#channel/user@example.com/' % ('a' * 32), { 'instance': plugins.NotifyPushBullet, }), - # APIKey + bad url + # , + ('pbul://%s' % ('a' * 32), { + 'instance': plugins.NotifyPushBullet, + # force a failure + 'response': False, + 'requests_response_code': requests.codes.internal_server_error, + }), + ('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, + }), + ('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, }), @@ -849,6 +867,98 @@ TEST_URLS = ( 'test_requests_exceptions': True, }), + + ################################## + # NotifyPushed + ################################## + ('pushed://', { + 'instance': None, + }), + # Application Key Only + ('pushed://%s' % ('a' * 32), { + 'instance': TypeError, + }), + # Application Key+Secret + ('pushed://%s/%s' % ('a' * 32, 'a' * 64), { + 'instance': plugins.NotifyPushed, + }), + # Application Key+Secret + channel + ('pushed://%s/%s/#channel/' % ('a' * 32, 'a' * 64), { + 'instance': plugins.NotifyPushed, + }), + # Application Key+Secret + dropped entry + ('pushed://%s/%s/dropped/' % ('a' * 32, 'a' * 64), { + 'instance': plugins.NotifyPushed, + }), + # Application Key+Secret + 2 channels + ('pushed://%s/%s/#channel1/#channel2' % ('a' * 32, 'a' * 64), { + 'instance': plugins.NotifyPushed, + }), + # Application Key+Secret + User Pushed ID + ('pushed://%s/%s/@ABCD/' % ('a' * 32, 'a' * 64), { + 'instance': plugins.NotifyPushed, + }), + # Application Key+Secret + 2 devices + ('pushed://%s/%s/@ABCD/@DEFG/' % ('a' * 32, 'a' * 64), { + 'instance': plugins.NotifyPushed, + }), + # Application Key+Secret + Combo + ('pushed://%s/%s/@ABCD/#channel' % ('a' * 32, 'a' * 64), { + 'instance': plugins.NotifyPushed, + }), + # , + ('pushed://%s/%s' % ('a' * 32, 'a' * 64), { + 'instance': plugins.NotifyPushed, + # force a failure + 'response': False, + 'requests_response_code': requests.codes.internal_server_error, + }), + ('pushed://%s/%s' % ('a' * 32, 'a' * 64), { + 'instance': plugins.NotifyPushed, + # throw a bizzare code forcing us to fail to look it up + 'response': False, + 'requests_response_code': 999, + }), + ('pushed://%s/%s' % ('a' * 32, 'a' * 64), { + 'instance': plugins.NotifyPushed, + # Throws a series of connection and transfer exceptions when this flag + # is set and tests that we gracfully handle them + 'test_requests_exceptions': True, + }), + ('pushed://:@/', { + 'instance': None, + }), + ('pushed://%s/%s' % ('a' * 32, 'a' * 64), { + 'instance': plugins.NotifyPushed, + # force a failure + 'response': False, + 'requests_response_code': requests.codes.internal_server_error, + }), + ('pushed://%s/%s' % ('a' * 32, 'a' * 64), { + 'instance': plugins.NotifyPushed, + # throw a bizzare code forcing us to fail to look it up + 'response': False, + 'requests_response_code': 999, + }), + ('pushed://%s/%s/#channel' % ('a' * 32, 'a' * 64), { + 'instance': plugins.NotifyPushed, + # throw a bizzare code forcing us to fail to look it up + 'response': False, + 'requests_response_code': 999, + }), + ('pushed://%s/%s/@user' % ('a' * 32, 'a' * 64), { + 'instance': plugins.NotifyPushed, + # throw a bizzare code forcing us to fail to look it up + 'response': False, + 'requests_response_code': 999, + }), + ('pushed://%s/%s' % ('a' * 32, 'a' * 64), { + 'instance': plugins.NotifyPushed, + # Throws a series of connection and transfer exceptions when this flag + # is set and tests that we gracfully handle them + 'test_requests_exceptions': True, + }), + ################################## # NotifyPushover ################################## @@ -2262,6 +2372,106 @@ def test_notify_pushbullet_plugin(mock_post, mock_get): assert(plugins.NotifyPushBullet.parse_url(42) is None) +@mock.patch('requests.get') +@mock.patch('requests.post') +def test_notify_pushed_plugin(mock_post, mock_get): + """ + API: NotifyPushed() Extra Checks + + """ + # Chat ID + recipients = '@ABCDEFG, @DEFGHIJ, #channel, #channel2' + + # Some required input + app_key = 'ABCDEFG' + app_secret = 'ABCDEFG' + + # Prepare Mock + mock_get.return_value = requests.Request() + mock_post.return_value = requests.Request() + mock_post.return_value.status_code = requests.codes.ok + mock_get.return_value.status_code = requests.codes.ok + mock_post.return_value.text = '' + mock_get.return_value.text = '' + + try: + obj = plugins.NotifyPushed( + app_key=app_key, + app_secret=None, + recipients=None, + ) + assert(False) + + except TypeError: + # No application Secret was specified; it's a good thing if + # this exception was thrown + assert(True) + + try: + obj = plugins.NotifyPushed( + app_key=app_key, + app_secret=app_secret, + recipients=None, + ) + # recipients list set to (None) is perfectly fine; in this + # case it will notify the App + assert(True) + + except TypeError: + # Exception should never be thrown! + assert(False) + + try: + obj = plugins.NotifyPushed( + app_key=app_key, + app_secret=app_secret, + recipients=object(), + ) + # invalid recipients list (object) + assert(False) + + except TypeError: + # Exception should be thrown about the fact no recipients were + # specified + assert(True) + + try: + obj = plugins.NotifyPushed( + app_key=app_key, + app_secret=app_secret, + recipients=set(), + ) + # Any empty set is acceptable + assert(True) + + except TypeError: + # Exception should never be thrown + assert(False) + + obj = plugins.NotifyPushed( + app_key=app_key, + app_secret=app_secret, + recipients=recipients, + ) + assert(isinstance(obj, plugins.NotifyPushed)) + assert(len(obj.channels) == 2) + assert(len(obj.users) == 2) + + # Disable throttling to speed up unit tests + obj.throttle_attempt = 0 + + # Support the handling of an empty and invalid URL strings + assert plugins.NotifyPushed.parse_url(None) is None + assert plugins.NotifyPushed.parse_url('') is None + assert plugins.NotifyPushed.parse_url(42) is None + + # Prepare Mock to fail + mock_post.return_value.status_code = requests.codes.internal_server_error + mock_get.return_value.status_code = requests.codes.internal_server_error + mock_post.return_value.text = '' + mock_get.return_value.text = '' + + @mock.patch('requests.get') @mock.patch('requests.post') def test_notify_pushover_plugin(mock_post, mock_get):