diff --git a/README.md b/README.md
index 5ba74e69..0e8e0ab3 100644
--- a/README.md
+++ b/README.md
@@ -45,6 +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
+| [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
+| [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
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
| [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
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/__init__.py b/apprise/plugins/__init__.py
index 30675004..fdc8e2f9 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
@@ -68,10 +69,10 @@ __all__ = [
'NotifyBoxcar', 'NotifyEmail', 'NotifyEmby', 'NotifyDiscord',
'NotifyFaast', 'NotifyGnome', 'NotifyGrowl', 'NotifyIFTTT', 'NotifyJoin',
'NotifyJSON', 'NotifyMatrix', 'NotifyMatterMost', 'NotifyProwl',
- 'NotifyPushalot', 'NotifyPushBullet', 'NotifyPushjet', 'NotifyPushover',
- 'NotifyRocketChat', 'NotifySlack', 'NotifyStride', 'NotifyToasty',
- 'NotifyTwitter', 'NotifyTelegram', 'NotifyXBMC', 'NotifyXML',
- 'NotifyWindows',
+ 'NotifyPushalot', 'NotifyPushed', 'NotifyPushBullet', 'NotifyPushjet',
+ 'NotifyPushover', 'NotifyRocketChat', 'NotifySlack', 'NotifyStride',
+ 'NotifyToasty', 'NotifyTwitter', 'NotifyTelegram', 'NotifyXBMC',
+ 'NotifyXML', 'NotifyWindows',
# Reference
'NotifyImageSize', 'NOTIFY_IMAGE_SIZES', 'NotifyType', 'NOTIFY_TYPES',
diff --git a/test/test_rest_plugins.py b/test/test_rest_plugins.py
index 63b41df8..266dc19b 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
##################################
@@ -2305,6 +2415,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):