From 55fb17280e21352a7fcec16b89a0ab6c6653f79c Mon Sep 17 00:00:00 2001 From: Chris Caron Date: Thu, 9 May 2019 10:06:20 -0400 Subject: [PATCH] Added Twilio Support (#106) --- README.md | 1 + apprise/plugins/NotifyTwilio.py | 385 +++++++++++++++++++++++++++ apprise/utils.py | 7 +- packaging/redhat/python-apprise.spec | 6 +- setup.py | 4 +- test/test_rest_plugins.py | 142 +++++++++- 6 files changed, 535 insertions(+), 10 deletions(-) create mode 100644 apprise/plugins/NotifyTwilio.py diff --git a/README.md b/README.md index 679af57d..4a82a582 100644 --- a/README.md +++ b/README.md @@ -59,6 +59,7 @@ The table below identifies the services this tool supports and some example serv | [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 +| [Twilio](https://github.com/caronc/apprise/wiki/Notify_twilio) | twilio:// | (TCP) 443 | twilio://AccountSid:AuthToken@FromPhoneNo
twilio://AccountSid:AuthToken@FromPhoneNo/ToPhoneNo
twilio://AccountSid:AuthToken@FromPhoneNo/ToPhoneNo1/ToPhoneNo2/ToPhoneNoN/
twilio://AccountSid:AuthToken@ShortCode/ToPhoneNo
twilio://AccountSid:AuthToken@ShortCode/ToPhoneNo1/ToPhoneNo2/ToPhoneNoN/ | [Twitter](https://github.com/caronc/apprise/wiki/Notify_twitter) | tweet:// | (TCP) 443 | tweet://user@CKey/CSecret/AKey/ASecret | [XBMC](https://github.com/caronc/apprise/wiki/Notify_xbmc) | xbmc:// or xbmcs:// | (TCP) 8080 or 443 | xbmc://hostname
xbmc://user@hostname
xbmc://user:password@hostname:port | [XMPP](https://github.com/caronc/apprise/wiki/Notify_xmpp) | xmpp:// or xmpps:// | (TCP) 5222 or 5223 | xmpp://password@hostname
xmpp://user:password@hostname
xmpps://user:password@hostname:port?jid=user@hostname/resource
xmpps://password@hostname/target@myhost, target2@myhost/resource diff --git a/apprise/plugins/NotifyTwilio.py b/apprise/plugins/NotifyTwilio.py new file mode 100644 index 00000000..90cb0877 --- /dev/null +++ b/apprise/plugins/NotifyTwilio.py @@ -0,0 +1,385 @@ +# -*- 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 service you will need a Twillio account to which you can get your +# AUTH_TOKEN and ACCOUNT SID right from your console/dashboard at: +# https://www.twilio.com/console +# +# You will also need to send the SMS From a phone number or account id name. + +# This is identified as the source (or where the SMS message will originate +# from). Activated phone numbers can be found on your dashboard here: +# - https://www.twilio.com/console/phone-numbers/incoming +# +# Alternatively, you can open your wallet and request a different Twilio +# phone # from: +# https://www.twilio.com/console/phone-numbers/search +# +# or consider purchasing a short-code from here: +# https://www.twilio.com/docs/glossary/what-is-a-short-code +# +import re +import requests +from json import loads + +from .NotifyBase import NotifyBase +from ..common import NotifyType +from ..utils import parse_list + + +# Used to validate your personal access apikey +VALIDATE_AUTH_TOKEN = re.compile(r'^[a-f0-9]{32}$', re.I) +VALIDATE_ACCOUNT_SID = re.compile(r'^AC[a-f0-9]{32}$', re.I) + +# Some Phone Number Detection +IS_PHONE_NO = re.compile(r'^\+?(?P[0-9\s)(+-]+)\s*$') + + +class NotifyTwilio(NotifyBase): + """ + A wrapper for Twilio Notifications + """ + + # The default descriptive name associated with the Notification + service_name = 'Twilio' + + # The services URL + service_url = 'https://www.twilio.com/' + + # All pushover requests are secure + secure_protocol = 'twilio' + + # Allow 300 requests per minute. + # 60/300 = 0.2 + request_rate_per_sec = 0.20 + + # the number of seconds undelivered messages should linger for + # in the Twilio queue + validity_period = 14400 + + # A URL that takes you to the setup/help of the specific protocol + setup_url = 'https://github.com/caronc/apprise/wiki/Notify_twilio' + + # Twilio uses the http protocol with JSON requests + notify_url = 'https://api.twilio.com/2010-04-01/Accounts/' \ + '{sid}/Messages.json' + + # The maximum length of the body + body_maxlen = 140 + + # A title can not be used for SMS Messages. Setting this to zero will + # cause any title (if defined) to get placed into the message body. + title_maxlen = 0 + + def __init__(self, account_sid, auth_token, source, targets=None, + **kwargs): + """ + Initialize Twilio Object + """ + super(NotifyTwilio, self).__init__(**kwargs) + + try: + # The Account SID associated with the account + self.account_sid = account_sid.strip() + + except AttributeError: + # Token was None + msg = 'No Account SID was specified.' + self.logger.warning(msg) + raise TypeError(msg) + + if not VALIDATE_ACCOUNT_SID.match(self.account_sid): + msg = 'The Account SID specified ({}) is invalid.' \ + .format(account_sid) + self.logger.warning(msg) + raise TypeError(msg) + + try: + # The authentication token associated with the account + self.auth_token = auth_token.strip() + + except AttributeError: + # Token was None + msg = 'No Auth Token was specified.' + self.logger.warning(msg) + raise TypeError(msg) + + if not VALIDATE_AUTH_TOKEN.match(self.auth_token): + msg = 'The Auth Token specified ({}) is invalid.' \ + .format(auth_token) + self.logger.warning(msg) + raise TypeError(msg) + + # The Source Phone # and/or short-code + self.source = source + + if not IS_PHONE_NO.match(self.source): + msg = 'The Account (From) Phone # or Short-code specified ' \ + '({}) is invalid.'.format(source) + self.logger.warning(msg) + raise TypeError(msg) + + # Tidy source + self.source = re.sub(r'[^\d]+', '', self.source) + + if len(self.source) < 11 or len(self.source) > 14: + # https://www.twilio.com/docs/glossary/what-is-a-short-code + # A short code is a special 5 or 6 digit telephone number + # that's shorter than a full phone number. + if len(self.source) not in (5, 6): + msg = 'The Account (From) Phone # specified ' \ + '({}) is invalid.'.format(source) + self.logger.warning(msg) + raise TypeError(msg) + + # else... it as a short code so we're okay + + else: + # We're dealing with a phone number; so we need to just + # place a plus symbol at the end of it + self.source = '+{}'.format(self.source) + + # Parse our targets + self.targets = list() + + for target in parse_list(targets): + # Validate targets and drop bad ones: + result = IS_PHONE_NO.match(target) + 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 # ' + '({}) specified.'.format(target), + ) + continue + + # store valid phone number + self.targets.append('+{}'.format(result)) + continue + + self.logger.warning( + 'Dropped invalid phone # ' + '({}) specified.'.format(target), + ) + + if len(self.targets) == 0: + msg = 'There are no valid targets identified to notify.' + if len(self.source) in (5, 6): + # raise a warning since we're a short-code. We need + # a number to message + self.logger.warning(msg) + raise TypeError(msg) + + def send(self, body, title='', notify_type=NotifyType.INFO, **kwargs): + """ + Perform Twilio Notification + """ + + # error tracking (used for function return) + has_error = False + + # Prepare our headers + headers = { + 'User-Agent': self.app_id, + 'Accept': 'application/json', + } + + # Prepare our payload + payload = { + 'Body': body, + 'From': self.source, + + # The To gets populated in the loop below + 'To': None, + } + + # Prepare our Twilio URL + url = self.notify_url.format(sid=self.account_sid) + + # Create a copy of the targets list + targets = list(self.targets) + + # Set up our authentication + auth = (self.account_sid, self.auth_token) + + if len(targets) == 0: + # No sources specified, use our own phone no + targets.append(self.source) + + while len(targets): + # Get our target to notify + target = targets.pop(0) + + # Prepare our user + payload['To'] = target + + # Some Debug Logging + self.logger.debug('Twilio POST URL: {} (cert_verify={})'.format( + url, self.verify_certificate)) + self.logger.debug('Twilio Payload: {}' .format(payload)) + + # Always call throttle before any remote server i/o is made + self.throttle() + try: + r = requests.post( + url, + auth=auth, + data=payload, + headers=headers, + verify=self.verify_certificate, + ) + + if r.status_code not in ( + requests.codes.created, requests.codes.ok): + # We had a problem + status_str = \ + NotifyBase.http_response_code_lookup(r.status_code) + + # set up our status code to use + status_code = r.status_code + + try: + # Update our status response if we can + json_response = loads(r.content) + status_code = json_response.get('code', status_code) + status_str = json_response.get('message', status_str) + + except (AttributeError, ValueError): + # could not parse JSON response... just use the status + # we already have. + + # AttributeError means r.content was None + pass + + self.logger.warning( + 'Failed to send Twilio notification to {}: ' + '{}{}error={}.'.format( + target, + status_str, + ', ' if status_str else '', + status_code)) + + self.logger.debug( + 'Response Details:\r\n{}'.format(r.content)) + + # Mark our failure + has_error = True + continue + + else: + self.logger.info( + 'Sent Twilio notification to {}.'.format(target)) + + except requests.RequestException as e: + self.logger.warning( + 'A Connection error occured sending Twilio:%s ' % ( + target) + 'notification.' + ) + self.logger.debug('Socket Exception: %s' % str(e)) + + # Mark our failure + has_error = True + continue + + return not has_error + + def url(self): + """ + Returns the URL built dynamically based on specified arguments. + """ + + # Define any arguments set + args = { + 'format': self.notify_format, + 'overflow': self.overflow_mode, + 'verify': 'yes' if self.verify_certificate else 'no', + } + + return '{schema}://{sid}:{token}@{source}/{targets}/?{args}'.format( + schema=self.secure_protocol, + sid=self.account_sid, + token=self.auth_token, + source=NotifyTwilio.quote(self.source, safe=''), + targets='/'.join( + [NotifyTwilio.quote(x, safe='') for x in self.targets]), + args=NotifyTwilio.urlencode(args)) + + @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, verify_host=False) + + if not results: + # We're done early as we couldn't load the results + return results + + # Get our entries; split_path() looks after unquoting content for us + # by default + results['targets'] = NotifyTwilio.split_path(results['fullpath']) + + # The hostname is our source number + results['source'] = NotifyTwilio.unquote(results['host']) + + # Get our account_side and auth_token from the user/pass config + results['account_sid'] = NotifyTwilio.unquote(results['user']) + results['auth_token'] = NotifyTwilio.unquote(results['password']) + + # Auth Token + if 'token' in results['qsd'] and len(results['qsd']['token']): + # Extract the account sid from an argument + results['auth_token'] = \ + NotifyTwilio.unquote(results['qsd']['token']) + + # Account SID + if 'sid' in results['qsd'] and len(results['qsd']['sid']): + # Extract the account sid from an argument + results['account_sid'] = \ + NotifyTwilio.unquote(results['qsd']['sid']) + + # Support the 'from' and 'source' variable so that we can support + # targets this way too. + # The 'from' makes it easier to use yaml configuration + if 'from' in results['qsd'] and len(results['qsd']['from']): + results['source'] = \ + NotifyTwilio.unquote(results['qsd']['from']) + if 'source' in results['qsd'] and len(results['qsd']['source']): + results['source'] = \ + NotifyTwilio.unquote(results['qsd']['source']) + + # Support the 'to' variable so that we can support targets this way too + # The 'to' makes it easier to use yaml configuration + if 'to' in results['qsd'] and len(results['qsd']['to']): + results['targets'] += \ + NotifyTwilio.parse_list(results['qsd']['to']) + + return results diff --git a/apprise/utils.py b/apprise/utils.py index c0177d78..f9a3e1b5 100644 --- a/apprise/utils.py +++ b/apprise/utils.py @@ -333,7 +333,6 @@ def parse_url(url, default_schema='http', verify_host=True): # Parse results result['host'] = parsed[1].strip() - if not result['host']: # Nothing more we can do without a hostname return None @@ -365,7 +364,7 @@ def parse_url(url, default_schema='http', verify_host=True): result['query'] = None try: (result['user'], result['host']) = \ - re.split(r'[\s@]+', result['host'])[:2] + re.split(r'[@]+', result['host'])[:2] except ValueError: # no problem then, host only exists @@ -375,7 +374,7 @@ def parse_url(url, default_schema='http', verify_host=True): if result['user'] is not None: try: (result['user'], result['password']) = \ - re.split(r'[:\s]+', result['user'])[:2] + re.split(r'[:]+', result['user'])[:2] except ValueError: # no problem then, user only exists @@ -384,7 +383,7 @@ def parse_url(url, default_schema='http', verify_host=True): try: (result['host'], result['port']) = \ - re.split(r'[\s:]+', result['host'])[:2] + re.split(r'[:]+', result['host'])[:2] except ValueError: # no problem then, user only exists diff --git a/packaging/redhat/python-apprise.spec b/packaging/redhat/python-apprise.spec index 6e22d706..8573e54d 100644 --- a/packaging/redhat/python-apprise.spec +++ b/packaging/redhat/python-apprise.spec @@ -49,9 +49,9 @@ it easy to access: Boxcar, Discord, E-Mail, Emby, Faast, Flock, Gitter, Gotify, Growl, IFTTT, Join, KODI, Mailgun, MatterMost, Matrix, Microsoft Windows Notifications, -Microsoft Teams, Notify My Android, Prowl, Pushalot, PushBullet, Pushjet, -Pushover, Rocket.Chat, Slack, Super Toasty, Stride, Telegram, Twitter, XBMC, -XMPP, Webex Teams} +Microsoft Teams, Notify MyAndroid, Prowl, Pushalot, PushBullet, Pushjet, +Pushover, Rocket.Chat, Slack, Super Toasty, Stride, Telegram, Twilio, Twitter, +XBMC, XMPP, Webex Teams} Name: python-%{pypi_name} Version: 0.7.6 diff --git a/setup.py b/setup.py index 31d3b0d5..885d7527 100755 --- a/setup.py +++ b/setup.py @@ -59,8 +59,8 @@ setup( keywords='Push Notifications Alerts Email AWS SNS Boxcar Discord Dbus ' 'Emby Faast Flock Gitter Gnome Gotify Growl IFTTT Join KODI Mailgun ' 'Matrix Mattermost Prowl PushBullet Pushjet Pushed Pushover ' - 'Rocket.Chat Ryver Slack Stride Telegram Twitter XBMC Microsoft ' - 'Windows Webex CLI API', + 'Rocket.Chat Ryver Slack Stride Telegram Twilio Twitter XBMC ' + 'Microsoft MSTeams Windows Webex CLI API', author='Chris Caron', author_email='lead2gold@gmail.com', packages=find_packages(), diff --git a/test/test_rest_plugins.py b/test/test_rest_plugins.py index 11c12eb9..7d5194a1 100644 --- a/test/test_rest_plugins.py +++ b/test/test_rest_plugins.py @@ -1895,6 +1895,87 @@ TEST_URLS = ( 'test_requests_exceptions': True, }), + ################################## + # NotifyTwilio + ################################## + ('twilio://', { + # No token specified + 'instance': None, + }), + ('twilio://:@/', { + # invalid Auth token + 'instance': TypeError, + }), + ('twilio://AC{}@12345678'.format('a' * 32), { + # Just sid provided + 'instance': TypeError, + }), + ('twilio://AC{}:{}@_'.format('a' * 32, 'b' * 32), { + # sid and token provided but invalid from + 'instance': TypeError, + }), + ('twilio://AC{}:{}@{}'.format('a' * 23, 'b' * 32, '1' * 11), { + # sid invalid and token + 'instance': TypeError, + }), + ('twilio://AC{}:{}@{}'.format('a' * 32, 'b' * 23, '2' * 11), { + # sid and invalid token + 'instance': TypeError, + }), + ('twilio://AC{}:{}@{}'.format('a' * 32, 'b' * 32, '3' * 5), { + # using short-code (5 characters) without a target + 'instance': TypeError, + }), + ('twilio://AC{}:{}@{}'.format('a' * 32, 'b' * 32, '3' * 9), { + # sid and token provided and from but invalid from no + 'instance': TypeError, + }), + ('twilio://AC{}:{}@{}/123/{}/abcd/'.format( + 'a' * 32, 'b' * 32, '3' * 11, '9' * 15), { + # valid everything but target numbers + 'instance': plugins.NotifyTwilio, + }), + ('twilio://AC{}:{}@12345/{}'.format('a' * 32, 'b' * 32, '4' * 11), { + # using short-code (5 characters) + 'instance': plugins.NotifyTwilio, + }), + ('twilio://AC{}:{}@123456/{}'.format('a' * 32, 'b' * 32, '4' * 11), { + # using short-code (6 characters) + 'instance': plugins.NotifyTwilio, + }), + ('twilio://AC{}:{}@{}'.format('a' * 32, 'b' * 32, '5' * 11), { + # using phone no with no target - we text ourselves in + # this case + 'instance': plugins.NotifyTwilio, + }), + ('twilio://_?sid=AC{}&token={}&from={}'.format( + 'a' * 32, 'b' * 32, '5' * 11), { + # use get args to acomplish the same thing + 'instance': plugins.NotifyTwilio, + }), + ('twilio://_?sid=AC{}&token={}&source={}'.format( + 'a' * 32, 'b' * 32, '5' * 11), { + # use get args to acomplish the same thing (use source instead of from) + 'instance': plugins.NotifyTwilio, + }), + ('twilio://_?sid=AC{}&token={}&from={}&to={}'.format( + 'a' * 32, 'b' * 32, '5' * 11, '7' * 13), { + # use to= + 'instance': plugins.NotifyTwilio, + }), + ('twilio://AC{}:{}@{}'.format('a' * 32, 'b' * 32, '6' * 11), { + 'instance': plugins.NotifyTwilio, + # throw a bizzare code forcing us to fail to look it up + 'response': False, + 'requests_response_code': 999, + }), + ('twilio://AC{}:{}@{}'.format('a' * 32, 'b' * 32, '6' * 11), { + 'instance': plugins.NotifyTwilio, + # Throws a series of connection and transfer exceptions when this flag + # is set and tests that we gracfully handle them + 'test_requests_exceptions': True, + }), + ################################## # NotifyWebexTeams ################################## @@ -2160,7 +2241,7 @@ def test_rest_plugins(mock_post, mock_get): if instance is not None: # We're done (assuming this is what we were expecting) print("{} didn't instantiate itself " - "(we expected it to)".format(url)) + "(we expected it to be a {})".format(url, instance)) assert False continue @@ -2682,6 +2763,65 @@ def test_notify_emby_plugin_sessions(mock_post, mock_get, mock_logout, assert len(sessions) == 0 +@mock.patch('requests.post') +def test_notify_twilio_plugin(mock_post): + """ + API: NotifyTwilio() Extra Checks + + """ + # Disable Throttling to speed testing + plugins.NotifyBase.NotifyBase.request_rate_per_sec = 0 + + # Prepare our response + response = requests.Request() + response.status_code = requests.codes.ok + + # Prepare Mock + mock_post.return_value = response + + # Initialize some generic (but valid) tokens + account_sid = 'AC{}'.format('b' * 32) + auth_token = '{}'.format('b' * 32) + source = '+1 (555) 123-3456' + + try: + plugins.NotifyTwilio( + account_sid=None, auth_token=auth_token, source=source) + # No account_sid specified + assert False + + except TypeError: + # Exception should be thrown about the fact account_sid was not + # specified + assert True + + try: + plugins.NotifyTwilio( + account_sid=account_sid, auth_token=None, source=source) + # No account_sid specified + assert False + + except TypeError: + # Exception should be thrown about the fact account_sid was not + # specified + assert True + + # a error response + response.status_code = 400 + response.content = dumps({ + 'code': 21211, + 'message': "The 'To' number +1234567 is not a valid phone number.", + }) + mock_post.return_value = response + + # Initialize our object + obj = plugins.NotifyTwilio( + account_sid=account_sid, auth_token=auth_token, source=source) + + # We will fail with the above error code + assert obj.notify('title', 'body', 'info') is False + + @mock.patch('apprise.plugins.NotifyEmby.login') @mock.patch('requests.get') @mock.patch('requests.post')