diff --git a/README.md b/README.md
index 63f0c50a..58579b3a 100644
--- a/README.md
+++ b/README.md
@@ -49,6 +49,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_
| [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
+| [Ryver](https://github.com/caronc/apprise/wiki/Notify_ryver) | ryver:// | (TCP) 443 | ryver://Organization/Token
ryver://botname@Organization/Token
| [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
| [Telegram](https://github.com/caronc/apprise/wiki/Notify_telegram) | tgram:// | (TCP) 443 | tgram://bottoken/ChatID
tgram://bottoken/ChatID1/ChatID2/ChatIDN
| [Twitter](https://github.com/caronc/apprise/wiki/Notify_twitter) | tweet:// | (TCP) 443 | tweet://user@CKey/CSecret/AKey/ASecret
diff --git a/apprise/plugins/NotifyRyver.py b/apprise/plugins/NotifyRyver.py
new file mode 100644
index 00000000..c0f6b81c
--- /dev/null
+++ b/apprise/plugins/NotifyRyver.py
@@ -0,0 +1,258 @@
+# -*- 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.
+
+# To use this plugin, you need to first generate a webhook.
+
+# When you're complete, you will recieve a URL that looks something like this:
+# https://apprise.ryver.com/application/webhook/ckhrjW8w672m6HG
+# ^ ^
+# | |
+# These are important <---^----------------------------------------^
+#
+import re
+import requests
+from json import dumps
+
+from .NotifyBase import NotifyBase
+from .NotifyBase import HTTP_ERROR_MAP
+from ..common import NotifyImageSize
+
+# Token required as part of the API request
+VALIDATE_TOKEN = re.compile(r'[A-Za-z0-9]{15}')
+
+# Organization required as part of the API request
+VALIDATE_ORG = re.compile(r'[A-Za-z0-9-]{3,32}')
+
+
+class RyverWebhookType(object):
+ """
+ Ryver supports to webhook types
+ """
+ SLACK = 'slack'
+ RYVER = 'ryver'
+
+
+# Define the types in a list for validation purposes
+RYVER_WEBHOOK_TYPES = (
+ RyverWebhookType.SLACK,
+ RyverWebhookType.RYVER,
+)
+
+
+class NotifyRyver(NotifyBase):
+ """
+ A wrapper for Ryver Notifications
+ """
+
+ # The default descriptive name associated with the Notification
+ service_name = 'Ryver'
+
+ # The services URL
+ service_url = 'https://ryver.com/'
+
+ # The default secure protocol
+ secure_protocol = 'ryver'
+
+ # A URL that takes you to the setup/help of the specific protocol
+ setup_url = 'https://github.com/caronc/apprise/wiki/Notify_ryver'
+
+ # Allows the user to specify the NotifyImageSize object
+ image_size = NotifyImageSize.XY_72
+
+ # The maximum allowable characters allowed in the body per message
+ body_maxlen = 1000
+
+ def __init__(self, organization, token, webhook=RyverWebhookType.RYVER,
+ **kwargs):
+ """
+ Initialize Ryver Object
+ """
+ super(NotifyRyver, self).__init__(**kwargs)
+
+ if not VALIDATE_TOKEN.match(token.strip()):
+ self.logger.warning(
+ 'The token specified (%s) is invalid.' % token,
+ )
+ raise TypeError(
+ 'The token specified (%s) is invalid.' % token,
+ )
+
+ if not VALIDATE_ORG.match(organization.strip()):
+ self.logger.warning(
+ 'The organization specified (%s) is invalid.' % organization,
+ )
+ raise TypeError(
+ 'The organization specified (%s) is invalid.' % organization,
+ )
+
+ # Store our webhook type
+ self.webhook = webhook
+
+ if self.webhook not in RYVER_WEBHOOK_TYPES:
+ self.logger.warning(
+ 'The webhook specified (%s) is invalid.' % webhook,
+ )
+ raise TypeError(
+ 'The webhook specified (%s) is invalid.' % webhook,
+ )
+
+ # The organization associated with the account
+ self.organization = organization.strip()
+
+ # The token associated with the account
+ self.token = token.strip()
+
+ # Slack formatting requirements are defined here which Ryver supports:
+ # https://api.slack.com/docs/message-formatting
+ self._re_formatting_map = {
+ # New lines must become the string version
+ r'\r\*\n': '\\n',
+ # Escape other special characters
+ r'&': '&',
+ r'<': '<',
+ r'>': '>',
+ }
+
+ # Iterate over above list and store content accordingly
+ self._re_formatting_rules = re.compile(
+ r'(' + '|'.join(self._re_formatting_map.keys()) + r')',
+ re.IGNORECASE,
+ )
+
+ def notify(self, title, body, notify_type, **kwargs):
+ """
+ Perform Ryver Notification
+ """
+
+ headers = {
+ 'User-Agent': self.app_id,
+ 'Content-Type': 'application/json',
+ }
+
+ if self.webhook == RyverWebhookType.SLACK:
+ # Perform Slack formatting
+ title = self._re_formatting_rules.sub( # pragma: no branch
+ lambda x: self._re_formatting_map[x.group()], title,
+ )
+ body = self._re_formatting_rules.sub( # pragma: no branch
+ lambda x: self._re_formatting_map[x.group()], body,
+ )
+
+ url = 'https://%s.ryver.com/application/webhook/%s' % (
+ self.organization,
+ self.token,
+ )
+
+ # prepare JSON Object
+ payload = {
+ "body": body if not title else '**%s**\r\n%s' % (title, body),
+ 'createSource': {
+ "displayName": self.user,
+ "avatar": self.image_url(notify_type),
+ },
+ }
+
+ self.logger.debug('Ryver POST URL: %s (cert_verify=%r)' % (
+ url, self.verify_certificate,
+ ))
+ self.logger.debug('Ryver Payload: %s' % str(payload))
+ try:
+ r = requests.post(
+ 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 Ryver:%s '
+ 'notification: %s (error=%s).' % (
+ self.organization,
+ HTTP_ERROR_MAP[r.status_code],
+ r.status_code))
+
+ except KeyError:
+ self.logger.warning(
+ 'Failed to send Ryver:%s '
+ 'notification (error=%s).' % (
+ self.organization,
+ r.status_code))
+
+ # self.logger.debug('Response Details: %s' % r.raw.read())
+
+ # Return; we're done
+ return False
+
+ else:
+ self.logger.info('Sent Ryver notification.')
+
+ except requests.RequestException as e:
+ self.logger.warning(
+ 'A Connection error occured sending Ryver:%s ' % (
+ self.organization) + 'notification.'
+ )
+ self.logger.debug('Socket Exception: %s' % str(e))
+ 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
+ organization = results['host']
+
+ # Now fetch the remaining tokens
+ try:
+ token = [x for x in filter(
+ bool, NotifyBase.split_path(results['fullpath']))][0]
+
+ except (ValueError, AttributeError, IndexError):
+ # We're done
+ return None
+
+ if 'webhook' in results['qsd'] and len(results['qsd']['webhook']):
+ results['webhook'] = results['qsd']\
+ .get('webhook', RyverWebhookType.RYVER).lower()
+
+ results['organization'] = organization
+ results['token'] = token
+
+ return results
diff --git a/apprise/plugins/__init__.py b/apprise/plugins/__init__.py
index 4ce3210c..9542c86d 100644
--- a/apprise/plugins/__init__.py
+++ b/apprise/plugins/__init__.py
@@ -46,6 +46,7 @@ from .NotifyPushBullet import NotifyPushBullet
from .NotifyPushjet.NotifyPushjet import NotifyPushjet
from .NotifyPushover import NotifyPushover
from .NotifyRocketChat import NotifyRocketChat
+from .NotifyRyver import NotifyRyver
from .NotifySlack import NotifySlack
from .NotifyTelegram import NotifyTelegram
from .NotifyTwitter.NotifyTwitter import NotifyTwitter
@@ -68,7 +69,7 @@ __all__ = [
'NotifyFaast', 'NotifyGnome', 'NotifyGrowl', 'NotifyIFTTT', 'NotifyJoin',
'NotifyJSON', 'NotifyMatrix', 'NotifyMatterMost', 'NotifyProwl',
'NotifyPushed', 'NotifyPushBullet', 'NotifyPushjet',
- 'NotifyPushover', 'NotifyRocketChat', 'NotifySlack',
+ 'NotifyPushover', 'NotifyRocketChat', 'NotifyRyver', 'NotifySlack',
'NotifyTwitter', 'NotifyTelegram', 'NotifyXBMC',
'NotifyXML', 'NotifyWindows',
diff --git a/test/test_rest_plugins.py b/test/test_rest_plugins.py
index 4670eecc..017e69ac 100644
--- a/test/test_rest_plugins.py
+++ b/test/test_rest_plugins.py
@@ -1054,6 +1054,64 @@ TEST_URLS = (
'test_requests_exceptions': True,
}),
+ ##################################
+ # NotifyRyver
+ ##################################
+ ('ryver://', {
+ 'instance': None,
+ }),
+ ('ryver://:@/', {
+ 'instance': None,
+ }),
+ ('ryver://apprise', {
+ # Just org provided (no token)
+ 'instance': None,
+ }),
+ ('ryver://abc,#/ckhrjW8w672m6HG', {
+ # Invalid org provided
+ 'instance': None,
+ }),
+ ('ryver://a/ckhrjW8w672m6HG', {
+ # org is too short
+ 'instance': TypeError,
+ }),
+ ('ryver://apprise/ckhrjW8w67HG', {
+ # Invalid token specified
+ 'instance': TypeError,
+ }),
+ ('ryver://apprise/ckhrjW8w672m6HG?webhook=invalid', {
+ # Invalid webhook provided
+ 'instance': TypeError,
+ }),
+ ('ryver://apprise/ckhrjW8w672m6HG?webhook=slack', {
+ # No username specified; this is still okay as we use whatever
+ # the user told the webhook to use; set our slack mode
+ 'instance': plugins.NotifyRyver,
+ }),
+ ('ryver://caronc@apprise/ckhrjW8w672m6HG', {
+ 'instance': plugins.NotifyRyver,
+ # don't include an image by default
+ 'include_image': False,
+ }),
+ ('ryver://apprise/ckhrjW8w672m6HG', {
+ 'instance': plugins.NotifyRyver,
+ # force a failure
+ 'response': False,
+ 'requests_response_code': requests.codes.internal_server_error,
+ }),
+ ('ryver://apprise/ckhrjW8w672m6HG', {
+ 'instance': plugins.NotifyRyver,
+ # throw a bizzare code forcing us to fail to look it up
+ 'response': False,
+ 'requests_response_code': 999,
+ }),
+ ('ryver://apprise/ckhrjW8w672m6HG', {
+ 'instance': plugins.NotifyRyver,
+ # Throws a series of connection and transfer exceptions when this flag
+ # is set and tests that we gracfully handle them
+ 'test_requests_exceptions': True,
+ }),
+
##################################
# NotifySlack
##################################