diff --git a/KEYWORDS b/KEYWORDS
index 3a58af59..8c7e4e87 100644
--- a/KEYWORDS
+++ b/KEYWORDS
@@ -1,5 +1,5 @@
Alerts
-API
+Apprise API
AWS
Boxcar
BulkSMS
@@ -7,32 +7,36 @@ Burst SMS
Chat
CLI
ClickSend
-DAPNET
-Dbus
-Dingtalk
+D7Networks
+Dapnet
+DBus
+DingTalk
Discord
Email
Emby
+Enigma2
Faast
FCM
Flock
+Form
Gitter
Gnome
-Google
+Google Chat
Gotify
Growl
Guilded
Home Assistant
IFTTT
Join
+JSON
Kavenegar
KODI
Kumulos
LaMetric
Line
-Mastodon
-MacOS
+MacOSX
Mailgun
+Mastodon
Matrix
Mattermost
MessageBird
@@ -41,7 +45,6 @@ Misskey
MQTT
MSG91
MSTeams
-Nexmo
Nextcloud
NextcloudTalk
Notica
@@ -61,6 +64,7 @@ Pushjet
Push Notifications
Pushover
PushSafer
+Pushy
Reddit
Rocket.Chat
Ryver
@@ -84,9 +88,11 @@ Telegram
Twilio
Twist
Twitter
+Voipms
Vonage
Webex
WhatsApp
Windows
-Voipms
XBMC
+XML
+Zulip
diff --git a/README.md b/README.md
index e3c61686..a00e9435 100644
--- a/README.md
+++ b/README.md
@@ -104,6 +104,7 @@ The table below identifies the services this tool supports and some example serv
| [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
| [PushSafer](https://github.com/caronc/apprise/wiki/Notify_pushsafer) | psafer:// or psafers:// | (TCP) 80 or 443 | psafer://privatekey
psafers://privatekey/DEVICE
psafer://privatekey/DEVICE1/DEVICE2/DEVICEN
+| [Pushy](https://github.com/caronc/apprise/wiki/Notify_pushy) | pushy:// | (TCP) 443 | pushy://apikey/DEVICE
pushy://apikey/DEVICE1/DEVICE2/DEVICEN
pushy://apikey/TOPIC
pushy://apikey/TOPIC1/TOPIC2/TOPICN
| [Reddit](https://github.com/caronc/apprise/wiki/Notify_reddit) | reddit:// | (TCP) 443 | reddit://user:password@app_id/app_secret/subreddit
reddit://user:password@app_id/app_secret/sub1/sub2/subN
| [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
rocket://webhook@hostname
rockets://webhook@hostname/@User/#Channel
| [Ryver](https://github.com/caronc/apprise/wiki/Notify_ryver) | ryver:// | (TCP) 443 | ryver://Organization/Token
ryver://botname@Organization/Token
diff --git a/apprise/plugins/NotifyPushy.py b/apprise/plugins/NotifyPushy.py
new file mode 100644
index 00000000..05ba6855
--- /dev/null
+++ b/apprise/plugins/NotifyPushy.py
@@ -0,0 +1,388 @@
+# -*- coding: utf-8 -*-
+# BSD 3-Clause License
+#
+# Apprise - Push Notification Library.
+# Copyright (c) 2023, Chris Caron
+#
+# Redistribution and use in source and binary forms, with or without
+# modification, are permitted provided that the following conditions are met:
+#
+# 1. Redistributions of source code must retain the above copyright notice,
+# this list of conditions and the following disclaimer.
+#
+# 2. Redistributions in binary form must reproduce the above copyright notice,
+# this list of conditions and the following disclaimer in the documentation
+# and/or other materials provided with the distribution.
+#
+# 3. Neither the name of the copyright holder nor the names of its
+# contributors may be used to endorse or promote products derived from
+# this software without specific prior written permission.
+#
+# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
+# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+# POSSIBILITY OF SUCH DAMAGE.
+
+# API reference: https://pushy.me/docs/api/send-notifications
+import re
+import requests
+from itertools import chain
+
+from json import dumps, loads
+from .NotifyBase import NotifyBase
+from ..common import NotifyType
+from ..utils import parse_list
+from ..utils import validate_regex
+from ..AppriseLocale import gettext_lazy as _
+
+# Used to detect a Device and Topic
+VALIDATE_DEVICE = re.compile(r'^@(?P[a-z0-9]+)$', re.I)
+VALIDATE_TOPIC = re.compile(r'^[#]?(?P[a-z0-9]+)$', re.I)
+
+# Extend HTTP Error Messages
+PUSHY_HTTP_ERROR_MAP = {
+ 401: 'Unauthorized - Invalid Token.',
+}
+
+
+class NotifyPushy(NotifyBase):
+ """
+ A wrapper for Pushy Notifications
+ """
+
+ # The default descriptive name associated with the Notification
+ service_name = 'Pushy'
+
+ # The services URL
+ service_url = 'https://pushy.me/'
+
+ # All Pushy requests are secure
+ secure_protocol = 'pushy'
+
+ # A URL that takes you to the setup/help of the specific protocol
+ setup_url = 'https://github.com/caronc/apprise/wiki/Notify_pushy'
+
+ # Pushy uses the http protocol with JSON requests
+ notify_url = 'https://api.pushy.me/push?api_key={apikey}'
+
+ # The maximum allowable characters allowed in the body per message
+ body_maxlen = 4096
+
+ # Define object templates
+ templates = (
+ '{schema}://{apikey}/{targets}',
+ )
+
+ # Define our template tokens
+ template_tokens = dict(NotifyBase.template_tokens, **{
+ 'apikey': {
+ 'name': _('Secret API Key'),
+ 'type': 'string',
+ 'private': True,
+ 'required': True,
+ },
+ 'target_device': {
+ 'name': _('Target Device'),
+ 'type': 'string',
+ 'prefix': '@',
+ 'map_to': 'targets',
+ },
+ 'target_topic': {
+ 'name': _('Target Topic'),
+ 'type': 'string',
+ 'prefix': '#',
+ 'map_to': 'targets',
+ },
+ 'targets': {
+ 'name': _('Targets'),
+ 'type': 'list:string',
+ 'required': True,
+ },
+ })
+
+ # Define our template arguments
+ template_args = dict(NotifyBase.template_args, **{
+ 'sound': {
+ # Specify something like ping.aiff
+ 'name': _('Sound'),
+ 'type': 'string',
+ },
+ 'badge': {
+ 'name': _('Badge'),
+ 'type': 'int',
+ 'min': 0,
+ },
+ 'to': {
+ 'alias_of': 'targets',
+ },
+ 'key': {
+ 'alias_of': 'apikey',
+ },
+ })
+
+ def __init__(self, apikey, targets=None, sound=None, badge=None, **kwargs):
+ """
+ Initialize Pushy Object
+ """
+ super().__init__(**kwargs)
+
+ # Access Token (associated with project)
+ self.apikey = validate_regex(apikey)
+ if not self.apikey:
+ msg = 'An invalid Pushy Secret API Key ' \
+ '({}) was specified.'.format(apikey)
+ self.logger.warning(msg)
+ raise TypeError(msg)
+
+ # Get our targets
+ self.devices = []
+ self.topics = []
+
+ for target in parse_list(targets):
+ result = VALIDATE_TOPIC.match(target)
+ if result:
+ self.topics.append(result.group('topic'))
+ continue
+
+ result = VALIDATE_DEVICE.match(target)
+ if result:
+ self.devices.append(result.group('device'))
+ continue
+
+ self.logger.warning(
+ 'Dropped invalid topic/device '
+ '({}) specified.'.format(target),
+ )
+
+ # Setup our sound
+ self.sound = sound
+
+ # Badge
+ try:
+ # Acquire our badge count if we can:
+ # - We accept both the integer form as well as a string
+ # representation
+ self.badge = int(badge)
+ if self.badge < 0:
+ raise ValueError()
+
+ except TypeError:
+ # NoneType means use Default; this is an okay exception
+ self.badge = None
+
+ except ValueError:
+ self.badge = None
+ self.logger.warning(
+ 'The specified Pushy badge ({}) is not valid ', badge)
+
+ return
+
+ def send(self, body, title='', notify_type=NotifyType.INFO, **kwargs):
+ """
+ Perform Pushy Notification
+ """
+
+ if len(self.topics) + len(self.devices) == 0:
+ # There were no services to notify
+ self.logger.warning('There were no Pushy targets to notify.')
+ return False
+
+ # error tracking (used for function return)
+ has_error = False
+
+ # Default Header
+ headers = {
+ 'User-Agent': self.app_id,
+ 'Content-Type': 'application/json',
+ 'Accepts': 'application/json',
+ }
+
+ # Our URL
+ notify_url = self.notify_url.format(apikey=self.apikey)
+
+ # Default content response object
+ content = {}
+
+ # Create a copy of targets (topics and devices)
+ targets = list(self.topics) + list(self.devices)
+ while len(targets):
+ target = targets.pop(0)
+
+ # prepare JSON Object
+ payload = {
+ # Mandatory fields
+ 'to': target,
+ "data": {
+ "message": body,
+ },
+ "notification": {
+ 'body': body,
+ }
+ }
+
+ # Optional payload items
+ if title:
+ payload['notification']['title'] = title
+
+ if self.sound:
+ payload['notification']['sound'] = self.sound
+
+ if self.badge is not None:
+ payload['notification']['badge'] = self.badge
+
+ self.logger.debug('Pushy POST URL: %s (cert_verify=%r)' % (
+ notify_url, self.verify_certificate,
+ ))
+ self.logger.debug('Pushy Payload: %s' % str(payload))
+
+ # Always call throttle before any remote server i/o is made
+ self.throttle()
+
+ try:
+ r = requests.post(
+ notify_url,
+ data=dumps(payload),
+ headers=headers,
+ verify=self.verify_certificate,
+ timeout=self.request_timeout,
+ )
+
+ # Sample response
+ # See: https://pushy.me/docs/api/send-notifications
+ # {
+ # "success": true,
+ # "id": "5ea9b214b47cad768a35f13a",
+ # "info": {
+ # "devices": 1
+ # "failed": ['abc']
+ # }
+ # }
+ try:
+ content = loads(r.content)
+
+ except (AttributeError, TypeError, ValueError):
+ # ValueError = r.content is Unparsable
+ # TypeError = r.content is None
+ # AttributeError = r is None
+ content = {
+ "success": False,
+ "id": '',
+ "info": {},
+ }
+
+ if r.status_code != requests.codes.ok \
+ or not content.get('success'):
+
+ # We had a problem
+ status_str = \
+ NotifyPushy.http_response_code_lookup(
+ r.status_code, PUSHY_HTTP_ERROR_MAP)
+
+ self.logger.warning(
+ 'Failed to send Pushy notification to {}: '
+ '{}{}error={}.'.format(
+ target,
+ status_str,
+ ', ' if status_str else '',
+ r.status_code))
+
+ self.logger.debug(
+ 'Response Details:\r\n{}'.format(r.content))
+
+ has_error = True
+ continue
+
+ else:
+ self.logger.info(
+ 'Sent Pushy notification to %s.' % target)
+
+ except requests.RequestException as e:
+ self.logger.warning(
+ 'A Connection error occurred sending Pushy:%s '
+ 'notification', target)
+ self.logger.debug('Socket Exception: %s' % str(e))
+
+ has_error = True
+ continue
+
+ return not has_error
+
+ def url(self, privacy=False, *args, **kwargs):
+ """
+ Returns the URL built dynamically based on specified arguments.
+ """
+
+ # Define any URL parameters
+ params = {}
+ if self.sound:
+ params['sound'] = self.sound
+
+ if self.badge is not None:
+ params['badge'] = str(self.badge)
+
+ # Extend our parameters
+ params.update(self.url_parameters(privacy=privacy, *args, **kwargs))
+
+ return '{schema}://{apikey}/{targets}/?{params}'.format(
+ schema=self.secure_protocol,
+ apikey=self.pprint(self.apikey, privacy, safe=''),
+ targets='/'.join(
+ [NotifyPushy.quote(x, safe='@#') for x in chain(
+ # Topics are prefixed with a pound/hashtag symbol
+ ['#{}'.format(x) for x in self.topics],
+ # Devices
+ ['@{}'.format(x) for x in self.devices],
+ )]),
+ params=NotifyPushy.urlencode(params))
+
+ def __len__(self):
+ """
+ Returns the number of targets associated with this notification
+ """
+ return len(self.topics) + len(self.devices)
+
+ @staticmethod
+ def parse_url(url):
+ """
+ Parses the URL and returns enough arguments that can allow
+ us to re-instantiate this object.
+
+ """
+ results = NotifyBase.parse_url(url, verify_host=False)
+ if not results:
+ # We're done early as we couldn't load the results
+ return results
+
+ # Token
+ results['apikey'] = NotifyPushy.unquote(results['host'])
+
+ # Retrieve all of our targets
+ results['targets'] = NotifyPushy.split_path(results['fullpath'])
+
+ # Get the sound
+ if 'sound' in results['qsd'] and len(results['qsd']['sound']):
+ results['sound'] = \
+ NotifyPushy.unquote(results['qsd']['sound'])
+
+ # Badge
+ if 'badge' in results['qsd'] and results['qsd']['badge']:
+ results['badge'] = NotifyPushy.unquote(
+ results['qsd']['badge'].strip())
+
+ # Support key variable to store Secret API Key
+ if 'key' in results['qsd'] and len(results['qsd']['key']):
+ results['apikey'] = results['qsd']['key']
+
+ # The 'to' makes it easier to use yaml configuration
+ if 'to' in results['qsd'] and len(results['qsd']['to']):
+ results['targets'] += \
+ NotifyPushy.parse_list(results['qsd']['to'])
+
+ return results
diff --git a/packaging/redhat/python-apprise.spec b/packaging/redhat/python-apprise.spec
index f827c2b3..5d82e063 100644
--- a/packaging/redhat/python-apprise.spec
+++ b/packaging/redhat/python-apprise.spec
@@ -50,7 +50,7 @@ LaMetric, Line, MacOSX, Mailgun, Mastodon, Mattermost, Matrix, MessageBird,
Microsoft Windows, Microsoft Teams, Misskey, MQTT, MSG91, MyAndroid, Nexmo,
Nextcloud, NextcloudTalk, Notica, Notifico, ntfy, Office365, OneSignal,
Opsgenie, PagerDuty, PagerTree, ParsePlatform, PopcornNotify, Prowl, Pushalot,
-PushBullet, Pushjet, Pushover, PushSafer, Reddit, Rocket.Chat, SendGrid,
+PushBullet, Pushjet, Pushover, PushSafer, Pushy, Reddit, Rocket.Chat, SendGrid,
ServerChan, Signal, SimplePush, Sinch, Slack, SMSEagle, SMTP2Go, Spontit,
SparkPost, Super Toasty, Streamlabs, Stride, Syslog, Techulus Push, Telegram,
Twilio, Twitter, Twist, XBMC, Voipms, Vonage, WhatsApp, Webex Teams}
diff --git a/test/helpers/rest.py b/test/helpers/rest.py
index 8980df7b..72edc282 100644
--- a/test/helpers/rest.py
+++ b/test/helpers/rest.py
@@ -332,9 +332,14 @@ class AppriseURLTester:
requests_response_text = dumps(requests_response_text)
requests_response_content = requests_response_text.encode('utf-8')
+ else:
+ requests_response_content = u''
+ requests_response_text = ''
+
# A request
robj = mock.Mock()
robj.content = u''
+ robj.text = ''
mock_get.return_value = robj
mock_post.return_value = robj
mock_head.return_value = robj
diff --git a/test/test_plugin_pushy.py b/test/test_plugin_pushy.py
new file mode 100644
index 00000000..38c1df37
--- /dev/null
+++ b/test/test_plugin_pushy.py
@@ -0,0 +1,161 @@
+# -*- coding: utf-8 -*-
+# BSD 3-Clause License
+#
+# Apprise - Push Notification Library.
+# Copyright (c) 2023, Chris Caron
+#
+# Redistribution and use in source and binary forms, with or without
+# modification, are permitted provided that the following conditions are met:
+#
+# 1. Redistributions of source code must retain the above copyright notice,
+# this list of conditions and the following disclaimer.
+#
+# 2. Redistributions in binary form must reproduce the above copyright notice,
+# this list of conditions and the following disclaimer in the documentation
+# and/or other materials provided with the distribution.
+#
+# 3. Neither the name of the copyright holder nor the names of its
+# contributors may be used to endorse or promote products derived from
+# this software without specific prior written permission.
+#
+# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
+# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+# POSSIBILITY OF SUCH DAMAGE.
+
+from apprise.plugins.NotifyPushy import NotifyPushy
+from helpers import AppriseURLTester
+
+# Disable logging for a cleaner testing output
+import logging
+logging.disable(logging.CRITICAL)
+
+
+# However we'll be okay if we return a proper response
+GOOD_RESPONSE = {
+ 'success': True,
+}
+
+# Our Testing URLs
+apprise_url_tests = (
+ ('pushy://', {
+ # No no secret api key
+ 'instance': TypeError,
+ }),
+ ('pushy://:@/', {
+ # just invalid all around
+ 'instance': TypeError,
+ }),
+ ('pushy://apikey', {
+ # No Device/Topic specified
+ 'instance': NotifyPushy,
+ # Expected notify() response False (because we won't be able
+ # to actually notify anything if no device_key was specified
+ 'notify_response': False,
+ 'requests_response_text': GOOD_RESPONSE,
+ }),
+ ('pushy://apikey/topic', {
+ # No Device/Topic specified
+ 'instance': NotifyPushy,
+ # Expected notify() response False because the success flag
+ # was set to false
+ 'notify_response': False,
+ 'requests_response_text': {'success': False}
+ }),
+ ('pushy://apikey/topic', {
+ # No Device/Topic specified
+ 'instance': NotifyPushy,
+ # Expected notify() response False because the success flag
+ # was set to false
+ 'notify_response': False,
+ # Invalid JSON data
+ 'requests_response_text': '}'
+ }),
+ ('pushy://apikey/%20(', {
+ # Invalid topic specified
+ 'instance': NotifyPushy,
+ # Expected notify() response False because there is no one to notify
+ 'notify_response': False,
+ 'requests_response_text': GOOD_RESPONSE,
+ }),
+ ('pushy://apikey/@device', {
+ # Everything is okay
+ 'instance': NotifyPushy,
+ 'requests_response_text': GOOD_RESPONSE,
+
+ # Our expected url(privacy=True) startswith() response:
+ 'privacy_url': 'pushy://a...y/@device/',
+ }),
+ ('pushy://apikey/topic', {
+ # Everything is okay; no prefix means it's a topic
+ 'instance': NotifyPushy,
+ 'requests_response_text': GOOD_RESPONSE,
+
+ # Our expected url(privacy=True) startswith() response:
+ 'privacy_url': 'pushy://a...y/#topic/',
+ }),
+ ('pushy://apikey/device/?sound=alarm.aiff', {
+ # alarm.aiff sound loaded
+ 'instance': NotifyPushy,
+ 'requests_response_text': GOOD_RESPONSE,
+ }),
+ ('pushy://apikey/device/?badge=100', {
+ # set badge
+ 'instance': NotifyPushy,
+ 'requests_response_text': GOOD_RESPONSE,
+ }),
+ ('pushy://apikey/device/?badge=invalid', {
+ # set invalid badge
+ 'instance': NotifyPushy,
+ 'requests_response_text': GOOD_RESPONSE,
+ }),
+ ('pushy://apikey/device/?badge=-12', {
+ # set invalid badge
+ 'instance': NotifyPushy,
+ 'requests_response_text': GOOD_RESPONSE,
+ }),
+ ('pushy://_/@device/#topic?key=apikey', {
+ # set device and topic
+ 'instance': NotifyPushy,
+ 'requests_response_text': GOOD_RESPONSE,
+ }),
+ ('pushy://apikey/?to=@device', {
+ # test use of to= argument
+ 'instance': NotifyPushy,
+ 'requests_response_text': GOOD_RESPONSE,
+ }),
+ ('pushy://_/@device/#topic?key=apikey', {
+ 'instance': NotifyPushy,
+ # throw a bizzare code forcing us to fail to look it up
+ 'response': False,
+ 'requests_response_code': 999,
+ 'requests_response_text': GOOD_RESPONSE,
+
+ # Our expected url(privacy=True) startswith() response:
+ 'privacy_url': 'pushy://a...y/#topic/@device/',
+ }),
+ ('pushy://_/@device/#topic?key=apikey', {
+ 'instance': NotifyPushy,
+ 'requests_response_text': GOOD_RESPONSE,
+ # Throws a series of connection and transfer exceptions when this flag
+ # is set and tests that we gracfully handle them
+ 'test_requests_exceptions': True,
+ }),
+)
+
+
+def test_plugin_pushy_urls():
+ """
+ NotifyPushy() Apprise URLs
+
+ """
+
+ # Run our general tests
+ AppriseURLTester(tests=apprise_url_tests).run_all()