diff --git a/README.md b/README.md index 63f0c50a..a2e2d8f5 100644 --- a/README.md +++ b/README.md @@ -31,6 +31,7 @@ The table below identifies the services this tool supports and some example serv | Notification Service | Service ID | Default Port | Example Syntax | | -------------------- | ---------- | ------------ | -------------- | +| [AWS SNS](https://github.com/caronc/apprise/wiki/Notify_sns) | sns:// | (TCP) 443 | sns://AccessKeyID/AccessSecretKey/RegionName/+PhoneNo
sns://AccessKeyID/AccessSecretKey/RegionName/+PhoneNo1/+PhoneNo2/+PhoneNoN
sns://AccessKeyID/AccessSecretKey/RegionName/Topic
sns://AccessKeyID/AccessSecretKey/RegionName/Topic1/Topic2/TopicN | [Boxcar](https://github.com/caronc/apprise/wiki/Notify_boxcar) | boxcar:// | (TCP) 443 | boxcar://hostname
boxcar://hostname/@tag
boxcar://hostname/device_token
boxcar://hostname/device_token1/device_token2/device_tokenN
boxcar://hostname/@tag/@tag2/device_token | [Discord](https://github.com/caronc/apprise/wiki/Notify_discord) | discord:// | (TCP) 443 | discord://webhook_id/webhook_token
discord://avatar@webhook_id/webhook_token | [Dbus](https://github.com/caronc/apprise/wiki/Notify_dbus) | dbus://
qt://
glib://
kde:// | n/a | dbus://
qt://
glib://
kde:// diff --git a/apprise/plugins/NotifySNS.py b/apprise/plugins/NotifySNS.py new file mode 100644 index 00000000..1cc7d859 --- /dev/null +++ b/apprise/plugins/NotifySNS.py @@ -0,0 +1,333 @@ +# -*- 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. + +import re + +# Phase 1; use boto3 for a proof of concept +import boto3 +from botocore.exceptions import ClientError +from botocore.exceptions import EndpointConnectionError + +from .NotifyBase import NotifyBase +from ..utils import compat_is_basestring + +# Some Phone Number Detection +IS_PHONE_NO = re.compile(r'^\+?(?P[0-9\s)(+-]+)\s*$') + +# Topic Detection +# Summary: 256 Characters max, only alpha/numeric plus underscore (_) and +# dash (-) additionally allowed. +# +# Soure: https://docs.aws.amazon.com/AWSSimpleQueueService/latest\ +# /SQSDeveloperGuide/sqs-limits.html#limits-queues +# +# Allow a starting hashtag (#) specification to help eliminate possible +# ambiguity between a topic that is comprised of all digits and a phone number +IS_TOPIC = re.compile(r'^#?(?P[A-Za-z0-9_-]+)\s*$') + +# Used to break apart list of potential tags by their delimiter +# into a usable list. +LIST_DELIM = re.compile(r'[ \t\r\n,\\/]+') + +# Because our AWS Access Key Secret contains slashes, we actually use the +# region as a delimiter. This is a bit hacky; but it's much easier than having +# users of this product search though this Access Key Secret and escape all +# of the forward slashes! +IS_REGION = re.compile( + r'^\s*(?P[a-z]{2})-(?P[a-z]+)-(?P[0-9]+)\s*$', re.I) + + +class NotifySNS(NotifyBase): + """ + A wrapper for AWS SNS (Amazon Simple Notification) + """ + + # The default descriptive name associated with the Notification + service_name = 'AWS Simple Notification Service (SNS)' + + # The services URL + service_url = 'https://aws.amazon.com/sns/' + + # The default secure protocol + secure_protocol = 'sns' + + # A URL that takes you to the setup/help of the specific protocol + setup_url = 'https://github.com/caronc/apprise/wiki/Notify_sns' + + # The maximum length of the body + body_maxlen = 256 + + def __init__(self, access_key_id, secret_access_key, region_name, + recipients=None, **kwargs): + """ + Initialize Notify AWS SNS Object + """ + super(NotifySNS, self).__init__(**kwargs) + + # Initialize topic list + self.topics = list() + + # Initialize numbers list + self.phone = list() + + # Store our AWS API Key + self.access_key_id = access_key_id + + # Store our AWS API Secret Access key + self.secret_access_key = secret_access_key + + # Acquire our AWS Region Name: + # eg. us-east-1, cn-north-1, us-west-2, ... + self.region_name = region_name + + if not access_key_id: + raise TypeError( + 'An invalid AWS Access Key ID was specified.' + ) + + if not secret_access_key: + raise TypeError( + 'An invalid AWS Secret Access Key was specified.' + ) + + if not (region_name and IS_REGION.match(region_name)): + raise TypeError( + 'An invalid AWS Region was specified.' + ) + + if recipients is None: + recipients = [] + + elif compat_is_basestring(recipients): + recipients = [x for x in filter(bool, LIST_DELIM.split( + recipients, + ))] + + elif not isinstance(recipients, (set, tuple, list)): + recipients = [] + + # Validate recipients and drop bad ones: + for recipient in recipients: + result = IS_PHONE_NO.match(recipient) + if result: + # Further check our phone # for it's digit count + # if it's less than 10, then we can assume it's + # a poorly specified phone no and spit a warning + result = ''.join(re.findall(r'\d+', result.group('phone'))) + if len(result) < 11 or len(result) > 14: + self.logger.warning( + 'Dropped invalid phone # ' + '(%s) specified.' % recipient, + ) + continue + + # store valid phone number + self.phone.append('+{}'.format(result)) + continue + + result = IS_TOPIC.match(recipient) + if result: + # store valid topic + self.topics.append(result.group('name')) + continue + + self.logger.warning( + 'Dropped invalid phone/topic ' + '(%s) specified.' % recipient, + ) + + if len(self.phone) == 0 and len(self.topics) == 0: + self.logger.warning( + 'There are no valid recipient identified to notify.') + + def notify(self, title, body, notify_type, **kwargs): + """ + wrapper to send_notification since we can alert more then one channel + """ + + # Create an SNS client + client = boto3.client( + "sns", + aws_access_key_id=self.access_key_id, + aws_secret_access_key=self.secret_access_key, + region_name=self.region_name, + ) + + # Initiaize our error tracking + has_error = False + + # Create a copy of our phone #'s and topics to notify against + phone = list(self.phone) + topics = list(self.topics) + + while len(phone) > 0: + # Get Phone No + no = phone.pop(0) + + try: + if not client.publish(PhoneNumber=no, Message=body): + + # toggle flag + has_error = True + + except ClientError as e: + self.logger.warning("The credentials specified were invalid.") + self.logger.debug('AWS Exception: %s' % str(e)) + + # We can take an early exit at this point since there + # is no need to potentialy get this error message again + # for the remaining (if any) topic/phone to process + return False + + except EndpointConnectionError as e: + self.logger.warning( + "The region specified is invalid.") + self.logger.debug('AWS Exception: %s' % str(e)) + + # We can take an early exit at this point since there + # is no need to potentialy get this error message again + # for the remaining (if any) topic/phone to process + return False + + if len(phone) + len(topics) > 0: + # Prevent thrashing requests + self.throttle() + + # Send all our defined topic id's + while len(topics): + # Get Topic + topic = topics.pop(0) + + # Create the topic if it doesn't exist; nothing breaks if it does + topic = client.create_topic(Name=topic) + + # Get the Amazon Resource Name + topic_arn = topic['TopicArn'] + + # Publish a message. + try: + if not client.publish(Message=body, TopicArn=topic_arn): + + # toggle flag + has_error = True + + except ClientError as e: + self.logger.warning( + "The credentials specified were invalid.") + self.logger.debug('AWS Exception: %s' % str(e)) + + # We can take an early exit at this point since there + # is no need to potentialy get this error message again + # for the remaining (if any) topics to process + return False + + except EndpointConnectionError as e: + self.logger.warning( + "The region specified is invalid.") + self.logger.debug('AWS Exception: %s' % str(e)) + + # We can take an early exit at this point since there + # is no need to potentialy get this error message again + # for the remaining (if any) topic/phone to process + return False + + if len(topics) > 0: + # Prevent thrashing requests + self.throttle() + + return not has_error + + @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 AWS Access Key ID is stored in the hostname + access_key_id = results['host'] + + # Our AWS Access Key Secret contains slashes in it which unfortunately + # means it is of variable length after the hostname. Since we require + # that the user provides the region code, we intentionally use this + # as our delimiter to detect where our Secret is. + secret_access_key = None + region_name = None + + # We need to iterate over each entry in the fullpath and find our + # region. Once we get there we stop and build our secret from our + # accumulated data. + secret_access_key_parts = list() + + # Section 1: Get Region and Access Secret + index = 0 + for i, entry in enumerate(NotifyBase.split_path(results['fullpath'])): + + # Are we at the region yet? + result = IS_REGION.match(entry) + if result: + # We found our Region; Rebuild our access key secret based on + # all entries we found prior to this: + secret_access_key = '/'.join(secret_access_key_parts) + + # Ensure region is nicely formatted + region_name = "{country}-{area}-{no}".format( + country=result.group('country').lower(), + area=result.group('area').lower(), + no=result.group('no'), + ) + + # Track our index as we'll use this to grab the remaining + # content in the next Section + index = i + 1 + + # We're done with Section 1 + break + + # Store our secret parts + secret_access_key_parts.append(entry) + + # Section 2: Get our Recipients (basically all remaining entries) + results['recipients'] = [ + NotifyBase.unquote(x) for x in filter(bool, NotifyBase.split_path( + results['fullpath']))][index:] + + # Store our other detected data (if at all) + results['region_name'] = region_name + results['access_key_id'] = access_key_id + results['secret_access_key'] = secret_access_key + + # Return our result set + return results diff --git a/apprise/plugins/__init__.py b/apprise/plugins/__init__.py index 4ce3210c..bffdce92 100644 --- a/apprise/plugins/__init__.py +++ b/apprise/plugins/__init__.py @@ -68,7 +68,7 @@ __all__ = [ 'NotifyFaast', 'NotifyGnome', 'NotifyGrowl', 'NotifyIFTTT', 'NotifyJoin', 'NotifyJSON', 'NotifyMatrix', 'NotifyMatterMost', 'NotifyProwl', 'NotifyPushed', 'NotifyPushBullet', 'NotifyPushjet', - 'NotifyPushover', 'NotifyRocketChat', 'NotifySlack', + 'NotifyPushover', 'NotifyRocketChat', 'NotifySlack', 'NotifySNS', 'NotifyTwitter', 'NotifyTelegram', 'NotifyXBMC', 'NotifyXML', 'NotifyWindows', diff --git a/requirements.txt b/requirements.txt index f6ef661f..d44a223d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -6,3 +6,4 @@ urllib3 six click >= 5.0 markdown +boto3 diff --git a/test/test_sns_plugin.py b/test/test_sns_plugin.py new file mode 100644 index 00000000..c80cd40c --- /dev/null +++ b/test/test_sns_plugin.py @@ -0,0 +1,307 @@ +# -*- 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. + +import mock +from botocore.exceptions import ClientError +from botocore.exceptions import EndpointConnectionError + +from apprise import plugins +from apprise import Apprise + +TEST_ACCESS_KEY_ID = 'AHIAJGNT76XIMXDBIJYA' +TEST_ACCESS_KEY_SECRET = 'bu1dHSdO22pfaaVy/wmNsdljF4C07D3bndi9PQJ9' +TEST_REGION = 'us-east-2' + + +@mock.patch('boto3.client') +def test_object_notifications(mock_client): + """ + API: NotifySNS Plugin() notifications + + """ + + # Create our object + a = Apprise() + assert(a.add('sns://oh/yeah/us-west-2/12223334444') is True) + # Multi Number Support + assert(a.add('sns://oh/yeah/us-west-2/12223334444/12223334445') is True) + + # Set a successful notification + client = mock.Mock() + client.publish.return_value = True + mock_client.return_value = client + assert(a.notify(title='', body='apprise notification') is True) + + # Set an unsuccessful notification + client = mock.Mock() + client.publish.return_value = False + mock_client.return_value = client + assert(a.notify(title='', body='apprise notification') is False) + + client = mock.Mock() + client.publish.return_value = True + client.publish.side_effect = \ + ClientError({'ResponseMetadata': {'RetryAttempts': 1}}, '') + mock_client.return_value = client + assert(a.notify(title='', body='apprise notification') is False) + + client = mock.Mock() + client.publish.return_value = True + client.publish.side_effect = EndpointConnectionError(endpoint_url='') + mock_client.return_value = client + assert(a.notify(title='', body='apprise notification') is False) + + # Create a new object + a = Apprise() + assert(a.add('sns://oh/yeah/us-east-2/ATopic') is True) + # Multi-Topic + assert(a.add('sns://oh/yeah/us-east-2/ATopic/AnotherTopic') is True) + + # Set a successful notification + client = mock.Mock() + client.publish.return_value = True + client.create_topic.return_value = {'TopicArn': 'goodtopic'} + mock_client.return_value = client + assert(a.notify(title='', body='apprise notification') is True) + + # Set an unsuccessful notification + client = mock.Mock() + client.publish.return_value = False + client.create_topic.return_value = {'TopicArn': 'goodtopic'} + mock_client.return_value = client + assert(a.notify(title='', body='apprise notification') is False) + + client = mock.Mock() + client.publish.return_value = True + client.publish.side_effect = \ + ClientError({'ResponseMetadata': {'RetryAttempts': 1}}, '') + client.create_topic.return_value = {'TopicArn': 'goodtopic'} + mock_client.return_value = client + assert(a.notify(title='', body='apprise notification') is False) + + client = mock.Mock() + client.publish.return_value = True + client.publish.side_effect = EndpointConnectionError(endpoint_url='') + client.create_topic.return_value = {'TopicArn': 'goodtopic'} + mock_client.return_value = client + assert(a.notify(title='', body='apprise notification') is False) + + # Create a new object + a = Apprise() + # Combiniation handling + assert(a.add('sns://oh/yeah/us-west-2/12223334444/ATopicToo') is True) + + client = mock.Mock() + client.publish.return_value = True + client.create_topic.return_value = {'TopicArn': 'goodtopic'} + mock_client.return_value = client + assert(a.notify(title='', body='apprise notification') is True) + + +def test_object_initialization(): + """ + API: NotifySNS Plugin() initialization + + """ + + # Initializes the plugin with a valid access, but invalid access key + try: + # No access_key_id specified + plugins.NotifySNS( + access_key_id=None, + secret_access_key=TEST_ACCESS_KEY_SECRET, + region_name=TEST_REGION, + recipients='+1800555999', + ) + # The entries above are invalid, our code should never reach here + assert(False) + + except TypeError: + # Exception correctly caught + assert(True) + + try: + # No secret_access_key specified + plugins.NotifySNS( + access_key_id=TEST_ACCESS_KEY_ID, + secret_access_key=None, + region_name=TEST_REGION, + recipients='+1800555999', + ) + # The entries above are invalid, our code should never reach here + assert(False) + + except TypeError: + # Exception correctly caught + assert(True) + + try: + # No region_name specified + plugins.NotifySNS( + access_key_id=TEST_ACCESS_KEY_ID, + secret_access_key=TEST_ACCESS_KEY_SECRET, + region_name=None, + recipients='+1800555999', + ) + # The entries above are invalid, our code should never reach here + assert(False) + + except TypeError: + # Exception correctly caught + assert(True) + + try: + # No recipients + plugins.NotifySNS( + access_key_id=TEST_ACCESS_KEY_ID, + secret_access_key=TEST_ACCESS_KEY_SECRET, + region_name=TEST_REGION, + recipients=None, + ) + # Still valid even without recipients + assert(True) + + except TypeError: + # Exception correctly caught + assert(False) + + try: + # No recipients - garbage recipients object + plugins.NotifySNS( + access_key_id=TEST_ACCESS_KEY_ID, + secret_access_key=TEST_ACCESS_KEY_SECRET, + region_name=TEST_REGION, + recipients=object(), + ) + # Still valid even without recipients + assert(True) + + except TypeError: + # Exception correctly caught + assert(False) + + try: + # The phone number is invalid, and without it, there is nothing + # to notify + plugins.NotifySNS( + access_key_id=TEST_ACCESS_KEY_ID, + secret_access_key=TEST_ACCESS_KEY_SECRET, + region_name=TEST_REGION, + recipients='+1809', + ) + # The recipient is invalid, but it's still okay; this Notification + # still becomes pretty much useless at this point though + assert(True) + + except TypeError: + # Exception correctly caught + assert(False) + + try: + # The phone number is invalid, and without it, there is nothing + # to notify; we + plugins.NotifySNS( + access_key_id=TEST_ACCESS_KEY_ID, + secret_access_key=TEST_ACCESS_KEY_SECRET, + region_name=TEST_REGION, + recipients='#(invalid-topic-because-of-the-brackets)', + ) + # The recipient is invalid, but it's still okay; this Notification + # still becomes pretty much useless at this point though + assert(True) + + except TypeError: + # Exception correctly caught + assert(False) + + +def test_url_parsing(): + """ + API: NotifySNS Plugin() URL Parsing + + """ + + # No recipients + results = plugins.NotifySNS.parse_url('sns://%s/%s/%s/' % ( + TEST_ACCESS_KEY_ID, + TEST_ACCESS_KEY_SECRET, + TEST_REGION) + ) + + # Confirm that there were no recipients found + assert(len(results['recipients']) == 0) + assert('region_name' in results) + assert(TEST_REGION == results['region_name']) + assert('access_key_id' in results) + assert(TEST_ACCESS_KEY_ID == results['access_key_id']) + assert('secret_access_key' in results) + assert(TEST_ACCESS_KEY_SECRET == results['secret_access_key']) + + # Detect recipients + results = plugins.NotifySNS.parse_url('sns://%s/%s/%s/%s/%s/' % ( + TEST_ACCESS_KEY_ID, + TEST_ACCESS_KEY_SECRET, + # Uppercase Region won't break anything + TEST_REGION.upper(), + '+18001234567', + 'MyTopic') + ) + + # Confirm that our recipients were found + assert(len(results['recipients']) == 2) + assert('+18001234567' in results['recipients']) + assert('MyTopic' in results['recipients']) + assert('region_name' in results) + assert(TEST_REGION == results['region_name']) + assert('access_key_id' in results) + assert(TEST_ACCESS_KEY_ID == results['access_key_id']) + assert('secret_access_key' in results) + assert(TEST_ACCESS_KEY_SECRET == results['secret_access_key']) + + +def test_object_parsing(): + """ + API: NotifySNS Plugin() Object Parsing + + """ + + # Create our object + a = Apprise() + + # Now test failing variations of our URL + assert(a.add('sns://') is False) + assert(a.add('sns://nosecret') is False) + assert(a.add('sns://nosecret/noregion/') is False) + + # This is valid, but a rather useless URL; there is nothing to notify + assert(a.add('sns://norecipient/norecipient/us-west-2') is True) + assert(len(a) == 1) + + # Parse a good one + assert(a.add('sns://oh/yeah/us-west-2/abcdtopic/+12223334444') is True) + assert(len(a) == 2) + + assert(a.add('sns://oh/yeah/us-west-2/12223334444') is True) + assert(len(a) == 3)