From c4b3d1768b46b7a4fbc9c6cd17f773cf3d06f2b9 Mon Sep 17 00:00:00 2001 From: Chris Caron Date: Tue, 6 Mar 2018 20:27:15 -0500 Subject: [PATCH] Added Stride Support + Testing; refs #6 --- README.md | 1 + apprise/plugins/NotifyStride.py | 275 ++++++++++++++++++++++++++++++++ apprise/plugins/__init__.py | 16 +- test/test_rest_plugins.py | 92 +++++++++++ 4 files changed, 377 insertions(+), 7 deletions(-) create mode 100644 apprise/plugins/NotifyStride.py diff --git a/README.md b/README.md index bba01f00..72a8140a 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,7 @@ The table below identifies the services this tool supports and some example serv | [Emby](https://github.com/caronc/apprise/wiki/Notify_emby) | emby:// or embys:// | (TCP) 8096 | emby://user@hostname/
emby://user:password@hostname | [Faast](https://github.com/caronc/apprise/wiki/Notify_faast) | faast:// | (TCP) 443 | faast://authorizationtoken | [Growl](https://github.com/caronc/apprise/wiki/Notify_growl) | growl:// | (UDP) 23053 | growl://hostname
growl://hostname:portno
growl://password@hostname
growl://password@hostname:port
**Note**: you can also use the get parameter _version_ which can allow the growl request to behave using the older v1.x protocol. An example would look like: growl://hostname?version=1 +| [Stride](https://github.com/caronc/apprise/wiki/Notify_stride) | stride:// | (TCP) 443 | stride://auth_token/cloud_id/convo_id | [Join](https://github.com/caronc/apprise/wiki/Notify_join) | join:// | (TCP) 443 | join://apikey/device
join://apikey/device1/device2/deviceN/
join://apikey/group
join://apikey/groupA/groupB/groupN
join://apikey/DeviceA/groupA/groupN/DeviceN/ | [KODI](https://github.com/caronc/apprise/wiki/Notify_kodi) | kodi:// or kodis:// | (TCP) 8080 or 443 | kodi://hostname
kodi://user@hostname
kodi://user:password@hostname:port | [Mattermost](https://github.com/caronc/apprise/wiki/Notify_mattermost) | mmost:// | (TCP) 8065 | mmost://hostname/authkey
mmost://hostname:80/authkey
mmost://user@hostname:80/authkey
mmost://hostname/authkey?channel=channel
mmosts://hostname/authkey
mmosts://user@hostname/authkey
diff --git a/apprise/plugins/NotifyStride.py b/apprise/plugins/NotifyStride.py new file mode 100644 index 00000000..94a11ffb --- /dev/null +++ b/apprise/plugins/NotifyStride.py @@ -0,0 +1,275 @@ +# -*- coding: utf-8 -*- +# +# Stride Notify Wrapper +# +# Copyright (C) 2018 Chris Caron +# +# This file is part of apprise. +# +# This program is free software; you can redistribute it and/or modify it +# under the terms of the GNU Lesser General Public License as published by +# the Free Software Foundation; either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Lesser General Public License for more details. + +# When you sign-up with stride.com they'll ask if you want to join a channel +# or create your own. +# +# Once you get set up, you'll have the option of creating a channel. +# +# Now you'll want to connect apprise up. To do this, you need to go to +# the App Manager an choose to 'Connect your own app'. It will get you +# to provide a 'token name' which can be whatever you want. Call it +# 'Apprise' if you want (it really doesn't matter) and then click the +# 'Create' button. +# +# When it completes it will generate a token that looks something like: +# HQFtq4pF8rKFOlKTm9Th +# +# This will become your AUTH_TOKEN +# +# It will also provide you a conversation URL that might look like: +# https://api.atlassian.com/site/ce171c45-79ae-4fec-a73d-5a4b7a322872/\ +# conversation/a54a80b3-eaad-4564-9a3a-f6653bcfb100/message +# +# Simplified, it looks like this: +# https://api.atlassian.com/site/CLOUD_ID/conversation/CONVO_ID/message +# +# This plugin will simply work using the url of: +# stride://AUTH_TOKEN/CLOUD_ID/CONVO_ID +# +import requests +import re +from json import dumps + +from .NotifyBase import NotifyBase +from .NotifyBase import HTTP_ERROR_MAP +from ..common import NotifyImageSize + +# Image Support (256x256) +STRIDE_IMAGE_XY = NotifyImageSize.XY_256 + +# A Simple UUID4 checker +IS_VALID_TOKEN = re.compile( + r'([0-9a-f]{8})-*([0-9a-f]{4})-*(4[0-9a-f]{3})-*' + r'([89ab][0-9a-f]{3})-*([0-9a-f]{12})', re.I) + + +class NotifyStride(NotifyBase): + """ + A wrapper to Stride Notifications + + """ + + # The default secure protocol + secure_protocol = 'stride' + + # Stride Webhook + notify_url = 'https://api.atlassian.com/site/{cloud_id}/' \ + 'conversation/{convo_id}/message' + + def __init__(self, auth_token, cloud_id, convo_id, **kwargs): + """ + Initialize Stride Object + + """ + super(NotifyStride, self).__init__( + title_maxlen=250, body_maxlen=2000, + image_size=STRIDE_IMAGE_XY, **kwargs) + + if not auth_token: + raise TypeError( + 'An invalid Authorization token was specified.' + ) + + if not cloud_id: + raise TypeError('No Cloud ID was specified.') + + cloud_id_re = IS_VALID_TOKEN.match(cloud_id) + if cloud_id_re is None: + raise TypeError('The specified Cloud ID is not a valid UUID.') + + if not convo_id: + raise TypeError('No Conversation ID was specified.') + + convo_id_re = IS_VALID_TOKEN.match(convo_id) + if convo_id_re is None: + raise TypeError( + 'The specified Conversation ID is not a valid UUID.') + + # Store our validated token + self.cloud_id = '{0}-{1}-{2}-{3}-{4}'.format( + cloud_id_re.group(0), + cloud_id_re.group(1), + cloud_id_re.group(2), + cloud_id_re.group(3), + cloud_id_re.group(4), + ) + + # Store our validated token + self.convo_id = '{0}-{1}-{2}-{3}-{4}'.format( + convo_id_re.group(0), + convo_id_re.group(1), + convo_id_re.group(2), + convo_id_re.group(3), + convo_id_re.group(4), + ) + + self.auth_token = auth_token + + return + + def notify(self, title, body, notify_type, **kwargs): + """ + Perform Stride Notification + """ + + headers = { + 'User-Agent': self.app_id, + 'Authorization': 'Bearer {auth_token}'.format( + auth_token=self.auth_token), + 'Content-Type': 'application/json', + } + + # Prepare JSON Object + payload = { + "body": { + "version": 1, + "type": "doc", + "content": [{ + "type": "paragraph", + "content": [{ + "type": "text", + "text": body, + }], + }], + } + } + + # payload = { + # # Text-To-Speech + # 'notify': self.tts, + + # # If Text-To-Speech is set to True, then we do not want to wait + # # for the whole message before continuing. Otherwise, we wait + # 'wait': self.tts is False, + + # # Our color associated with our notification + # 'color': self.color(notify_type, int), + + # 'embeds': [{ + # 'provider': { + # 'name': self.app_id, + # 'url': self.app_url, + # }, + # 'title': title, + # 'type': 'rich', + # 'description': body, + # }] + # } + + # image_url = self.image_url(notify_type) + # if image_url: + # if self.thumbnail: + # payload['embeds'][0]['thumbnail'] = { + # 'url': image_url, + # 'height': 256, + # 'width': 256, + # } + + # if self.avatar: + # payload['avatar_url'] = image_url + + # if self.user: + # # Optionally override the default username of the webhook + # payload['username'] = self.user + + # Construct Notify URL + notify_url = self.notify_url.format( + cloud_id=self.cloud_id, + convo_id=self.convo_id, + ) + + self.logger.debug('Stride POST URL: %s (cert_verify=%r)' % ( + notify_url, self.verify_certificate, + )) + self.logger.debug('Stride Payload: %s' % str(payload)) + try: + r = requests.post( + notify_url, + data=dumps(payload), + headers=headers, + verify=self.verify_certificate, + ) + if r.status_code not in ( + requests.codes.ok, requests.codes.no_content): + # We had a problem + try: + self.logger.warning( + 'Failed to send Stride notification: ' + '%s (error=%s).' % ( + HTTP_ERROR_MAP[r.status_code], + r.status_code)) + + except KeyError: + self.logger.warning( + 'Failed to send Stride 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 Stride notification.') + + except requests.RequestException as e: + self.logger.warning( + 'A Connection error occured sending Stride ' + '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. + + Syntax: + stride://auth_token/cloud_id/convo_id + + """ + results = NotifyBase.parse_url(url) + + if not results: + # We're done early as we couldn't load the results + return results + + # Store our Authentication Token + auth_token = results['host'] + + # Now fetch our tokens + try: + (ta, tb) = [x for x in filter(bool, NotifyBase.split_path( + results['fullpath']))][0:2] + + except (ValueError, AttributeError, IndexError): + # Force some bad values that will get caught + # in parsing later + ta = None + tb = None + + results['cloud_id'] = ta + results['convo_id'] = tb + results['auth_token'] = auth_token + + return results diff --git a/apprise/plugins/__init__.py b/apprise/plugins/__init__.py index 9fe62d83..e6f4f62f 100644 --- a/apprise/plugins/__init__.py +++ b/apprise/plugins/__init__.py @@ -26,7 +26,7 @@ from .NotifyEmail import NotifyEmail from .NotifyEmby import NotifyEmby from .NotifyFaast import NotifyFaast from .NotifyGrowl.NotifyGrowl import NotifyGrowl -from .NotifyGrowl import gntp +from .NotifyJoin import NotifyJoin from .NotifyJSON import NotifyJSON from .NotifyMatterMost import NotifyMatterMost from .NotifyMyAndroid import NotifyMyAndroid @@ -34,17 +34,19 @@ from .NotifyProwl import NotifyProwl from .NotifyPushalot import NotifyPushalot from .NotifyPushBullet import NotifyPushBullet from .NotifyPushjet.NotifyPushjet import NotifyPushjet -from .NotifyPushjet import pushjet from .NotifyPushover import NotifyPushover from .NotifyRocketChat import NotifyRocketChat +from .NotifySlack import NotifySlack +from .NotifyStride import NotifyStride from .NotifyTelegram import NotifyTelegram from .NotifyToasty import NotifyToasty from .NotifyTwitter.NotifyTwitter import NotifyTwitter -from .NotifyTwitter import tweepy from .NotifyXBMC import NotifyXBMC from .NotifyXML import NotifyXML -from .NotifySlack import NotifySlack -from .NotifyJoin import NotifyJoin + +from .NotifyPushjet import pushjet +from .NotifyGrowl import gntp +from .NotifyTwitter import tweepy from ..common import NotifyImageSize from ..common import NOTIFY_IMAGE_SIZES @@ -57,8 +59,8 @@ __all__ = [ 'NotifyFaast', 'NotifyGrowl', 'NotifyJoin', 'NotifyJSON', 'NotifyMatterMost', 'NotifyMyAndroid', 'NotifyProwl', 'NotifyPushalot', 'NotifyPushBullet', 'NotifyPushjet', 'NotifyPushover', 'NotifyRocketChat', - 'NotifySlack', 'NotifyToasty', 'NotifyTwitter', 'NotifyTelegram', - 'NotifyXBMC', 'NotifyXML', + 'NotifySlack', 'NotifyStride', 'NotifyToasty', 'NotifyTwitter', + 'NotifyTelegram', 'NotifyXBMC', 'NotifyXML', # Reference 'NotifyImageSize', 'NOTIFY_IMAGE_SIZES', 'NotifyType', 'NOTIFY_TYPES', diff --git a/test/test_rest_plugins.py b/test/test_rest_plugins.py index ca4c360a..4e1264c7 100644 --- a/test/test_rest_plugins.py +++ b/test/test_rest_plugins.py @@ -22,6 +22,7 @@ from apprise import Apprise from apprise import AppriseAsset from apprise.utils import compat_is_basestring from json import dumps +import uuid import requests import mock @@ -233,6 +234,63 @@ TEST_URLS = ( 'test_requests_exceptions': True, }), + ################################## + # NotifyStride + ################################## + # no auth_key specified + ('stride://', { + 'instance': None, + }), + # No token_a specified + ('stride://auth_key', { + # Missing a token + 'instance': TypeError, + }), + # No token_b specified + ('stride://auth_key/{0}'.format( + str(uuid.uuid4())), { + 'instance': TypeError, + }), + # invalid uuid entries + ('stride://auth_key/{0}/{1}'.format( + 'invalid-uuid', str(uuid.uuid4())), { + 'instance': TypeError, + }), + ('stride://auth_key/{0}/{1}'.format( + str(uuid.uuid4()), 'invalid-uuid'), { + 'instance': TypeError, + }), + # A valid url + ('stride://auth_key/{0}/{1}'.format( + str(uuid.uuid4()), str(uuid.uuid4())), { + 'instance': plugins.NotifyStride, + }), + # A very invalid URL + ('stride://:@/', { + 'instance': None, + }), + ('stride://auth_key/{0}/{1}'.format( + str(uuid.uuid4()), str(uuid.uuid4())), { + 'instance': plugins.NotifyStride, + # force a failure + 'response': False, + 'requests_response_code': requests.codes.internal_server_error, + }), + ('stride://auth_key/{0}/{1}'.format( + str(uuid.uuid4()), str(uuid.uuid4())), { + 'instance': plugins.NotifyStride, + # throw a bizzare code forcing us to fail to look it up + 'response': False, + 'requests_response_code': 999, + }), + ('stride://auth_key/{0}/{1}'.format( + str(uuid.uuid4()), str(uuid.uuid4())), { + 'instance': plugins.NotifyStride, + # Throws a series of connection and transfer exceptions when this flag + # is set and tests that we gracfully handle them + 'test_requests_exceptions': True, + }), + ################################## # NotifyJoin ################################## @@ -1902,6 +1960,40 @@ def test_notify_emby_plugin_notify(mock_post, mock_get, mock_logout, assert obj.notify('title', 'body', 'info') is True +def test_notify_stride_plugin(): + """ + API: NotifyStride() Extra Checks + + """ + try: + # Initializes the plugin with devices set to a string + plugins.NotifyStride( + auth_token=None, + cloud_id=str(uuid.uuid4()), + convo_id=str(uuid.uuid4()), + ) + # The code shouldn't make it here, we should throw an exception + # on the previous line + assert False + + except TypeError: + assert True + + try: + # Initializes the plugin with devices set to a string + plugins.NotifyStride( + auth_token='key', + cloud_id=str(uuid.uuid4()), + convo_id=None, + ) + # The code shouldn't make it here, we should throw an exception + # on the previous line + assert False + + except TypeError: + assert True + + @mock.patch('requests.get') @mock.patch('requests.post') def test_notify_join_plugin(mock_post, mock_get):