From d515f99881b3ddb9078b7f35c31e50fb71658f40 Mon Sep 17 00:00:00 2001 From: Chris Caron Date: Sat, 9 Feb 2019 17:24:32 -0500 Subject: [PATCH] Added Ryver support; refs #54 --- README.md | 1 + apprise/plugins/NotifyRyver.py | 258 +++++++++++++++++++++++++++++++++ apprise/plugins/__init__.py | 3 +- test/test_rest_plugins.py | 58 ++++++++ 4 files changed, 319 insertions(+), 1 deletion(-) create mode 100644 apprise/plugins/NotifyRyver.py 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 ##################################