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):