diff --git a/apprise/plugins/clickatell.py b/apprise/plugins/clickatell.py index 053eb06b..87d2ddd7 100644 --- a/apprise/plugins/clickatell.py +++ b/apprise/plugins/clickatell.py @@ -26,16 +26,15 @@ # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. -# To use this service you will need a Clickatell account to which you can get your -# API_TOKEN at: +# To use this service you will need a Clickatell account to which you can get +# your API_TOKEN at: # https://www.clickatell.com/ import requests - +from itertools import chain from .base import NotifyBase from ..common import NotifyType from ..locale import gettext_lazy as _ -from ..url import PrivacyMode -from ..utils.parse import validate_regex, parse_phone_no +from ..utils.parse import is_phone_no, validate_regex, parse_phone_no class NotifyClickatell(NotifyBase): @@ -50,18 +49,18 @@ class NotifyClickatell(NotifyBase): notify_url = 'https://platform.clickatell.com/messages/http/send?apiKey={}' templates = ( - '{schema}://{api_token}/{targets}', - '{schema}://{api_token}@{from_phone}/{targets}', + '{schema}://{apikey}/{targets}', + '{schema}://{source}@{apikey}/{targets}', ) template_tokens = dict(NotifyBase.template_tokens, **{ - 'api_token': { + 'apikey': { 'name': _('API Token'), 'type': 'string', 'private': True, 'required': True, }, - 'from_phone': { + 'source': { 'name': _('From Phone No'), 'type': 'string', 'regex': (r'^[0-9\s)(+-]+$', 'i'), @@ -81,33 +80,72 @@ class NotifyClickatell(NotifyBase): }) template_args = dict(NotifyBase.template_args, **{ - 'token': { - 'alias_of': 'api_token' + 'apikey': { + 'alias_of': 'apikey' }, 'to': { 'alias_of': 'targets', }, 'from': { - 'alias_of': 'from_phone', + 'alias_of': 'source', }, }) - def __init__(self, api_token, from_phone, targets=None, **kwargs): + def __init__(self, apikey, source=None, targets=None, **kwargs): """ Initialize Clickatell Object """ super().__init__(**kwargs) - self.api_token = validate_regex(api_token) - if not self.api_token: + self.apikey = validate_regex(apikey) + if not self.apikey: msg = 'An invalid Clickatell API Token ' \ - '({}) was specified.'.format(api_token) + '({}) was specified.'.format(apikey) self.logger.warning(msg) raise TypeError(msg) - self.from_phone = validate_regex(from_phone) - self.targets = parse_phone_no(targets, prefix=True) + self.source = None + if source: + result = is_phone_no(source) + if not result: + msg = 'The Account (From) Phone # specified ' \ + '({}) is invalid.'.format(source) + self.logger.warning(msg) + + raise TypeError(msg) + + # Tidy source + self.source = result['full'] + + # Used for URL generation afterwards only + self._invalid_targets = list() + + # Parse our targets + self.targets = list() + + for target in parse_phone_no(targets, prefix=True): + # Validate targets and drop bad ones: + result = is_phone_no(target) + if not result: + self.logger.warning( + 'Dropped invalid phone # ' + '({}) specified.'.format(target), + ) + self._invalid_targets.append(target) + continue + + # store valid phone number + self.targets.append(result['full']) + + @property + def url_identifier(self): + """ + Returns all of the identifiers that make this URL unique from + another simliar one. Targets or end points should never be identified + here. + """ + return (self.apikey, self.source) def url(self, privacy=False, *args, **kwargs): """ @@ -116,12 +154,24 @@ class NotifyClickatell(NotifyBase): params = self.url_parameters(privacy=privacy, *args, **kwargs) - return '{schema}://{apikey}/?{params}'.format( + return '{schema}://{source}{apikey}/{targets}/?{params}'.format( schema=self.secure_protocol, - apikey=self.quote(self.api_token, safe='/'), + source='{}@'.format(self.source) if self.source else '', + apikey=self.pprint(self.apikey, privacy, safe='='), + targets='/'.join( + [NotifyClickatell.quote(t, safe='') + for t in chain(self.targets, self._invalid_targets)]), params=self.urlencode(params), ) + def __len__(self): + """ + Returns the number of targets associated with this notification + + Always return 1 at least + """ + return len(self.targets) if self.targets else 1 + def send(self, body, title='', notify_type=NotifyType.INFO, **kwargs): """ Perform Clickatell Notification @@ -137,9 +187,9 @@ class NotifyClickatell(NotifyBase): 'Content-Type': 'application/json', } - url = self.notify_url.format(self.api_token) - if self.from_phone: - url += '&from={}'.format(self.from_phone) + url = self.notify_url.format(self.apikey) + if self.source: + url += '&from={}'.format(self.source) url += '&to={}'.format(','.join(self.targets)) url += '&content={}'.format(' '.join([title, body])) @@ -172,6 +222,7 @@ class NotifyClickatell(NotifyBase): return False else: self.logger.info('Sent Clickatell notification.') + except requests.RequestException as e: self.logger.warning( 'A Connection error occurred sending Clickatell ' @@ -192,15 +243,22 @@ class NotifyClickatell(NotifyBase): return results results['targets'] = NotifyClickatell.split_path(results['fullpath']) - - if not results['targets']: - return results + results['apikey'] = NotifyClickatell.unquote(results['host']) if results['user']: - results['api_token'] = NotifyClickatell.unquote(results['user']) - results['from_phone'] = NotifyClickatell.unquote(results['host']) - else: - results['api_token'] = NotifyClickatell.unquote(results['host']) - results['from_phone'] = '' + results['source'] = NotifyClickatell.unquote(results['user']) + + # 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'] += \ + NotifyClickatell.parse_phone_no(results['qsd']['to']) + + # 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'] = \ + NotifyClickatell.unquote(results['qsd']['from']) return results diff --git a/test/test_plugin_clickatell.py b/test/test_plugin_clickatell.py index d9cfbfcb..90ae2894 100644 --- a/test/test_plugin_clickatell.py +++ b/test/test_plugin_clickatell.py @@ -45,64 +45,76 @@ apprise_url_tests = ( 'instance': TypeError, }), ('clickatell:///', { - # invalid api_token + # invalid apikey 'instance': TypeError, }), ('clickatell://@/', { - # invalid api_token + # invalid apikey 'instance': TypeError, }), + ('clickatell://{}@/'.format('1' * 10), { + # no api key provided + 'instance': TypeError, + }), + ('clickatell://{}@{}/'.format('1' * 3, 'a' * 32), { + # invalid From/Source + 'instance': TypeError + }), ('clickatell://{}/'.format('a' * 32), { # no targets provided - 'instance': TypeError, + 'instance': NotifyClickatell, + # We have no one to notify + 'notify_response': False, }), - ('clickatell://{}@/'.format('a' * 32), { - # no targets provided - 'instance': TypeError, - }), - ('clickatell://{}@{}/'.format('a' * 32, '1' * 9), { - # no targets provided - 'instance': TypeError, - }), - ('clickatell://{}@{}'.format('a' * 32, 'b' * 32, '3' * 9), { - # no targets provided - 'instance': TypeError, + ('clickatell://{}@{}/'.format('1' * 10, 'a' * 32), { + # no targets provided (no one to notify) + 'instance': NotifyClickatell, + # We have no one to notify + 'notify_response': False, }), ('clickatell://{}@{}/123/{}/abcd'.format( - 'a' * 32, '1' * 6, '3' * 11), { - # valid everything but target numbers - 'instance': NotifyClickatell, - }), - ('clickatell://{}/{}'.format('a' * 32, '1' * 9), { + '1' * 10, 'a' * 32, '3' * 15), { + # valid everything but target numbers + 'instance': NotifyClickatell, + # We have no one to notify + 'notify_response': False, + }), + ('clickatell://{}/{}'.format('1' * 10, 'a' * 32), { + # everything valid (no source defined) + 'instance': NotifyClickatell, + # We have no one to notify + 'notify_response': False, + }), + ('clickatell://{}@{}/{}'.format('1' * 10, 'a' * 32, '1' * 10), { # everything valid 'instance': NotifyClickatell, }), - ('clickatell://{}@{}/{}'.format('a' * 32, '1' * 9, '1' * 9), { - # everything valid + ('clickatell://{}/{}'.format('a' * 32, '1' * 10), { + # everything valid (no source) 'instance': NotifyClickatell, }), - ('clickatell://_?token={}&from={}&to={},{}'.format( - 'a' * 32, '1' * 9, '1' * 9, '1' * 9), { - # use get args to accomplish the same thing - 'instance': NotifyClickatell, - }), - ('clickatell://_?token={}'.format('a' * 32), { + ('clickatell://_?apikey={}&from={}&to={},{}'.format( + 'a' * 32, '1' * 10, '1' * 10, '1' * 10), { + # use get args to accomplish the same thing + 'instance': NotifyClickatell, + }), + ('clickatell://_?apikey={}'.format('a' * 32), { # use get args 'instance': NotifyClickatell, 'notify_response': False, }), - ('clickatell://_?token={}&from={}'.format('a' * 32, '1' * 9), { + ('clickatell://_?apikey={}&from={}'.format('a' * 32, '1' * 10), { # use get args 'instance': NotifyClickatell, 'notify_response': False, }), - ('clickatell://{}/{}'.format('a' * 32, '1' * 9), { + ('clickatell://{}@{}/{}'.format('1' * 10, 'a' * 32, '1' * 10), { 'instance': NotifyClickatell, # throw a bizarre code forcing us to fail to look it up 'response': False, 'requests_response_code': 999, }), - ('clickatell://{}@{}/{}'.format('a' * 32, '1' * 9, '1' * 9), { + ('clickatell://{}@{}/{}'.format('1' * 10, 'a' * 32, '1' * 10), { 'instance': NotifyClickatell, # Throws a series of connection and transfer exceptions when this flag # is set and tests that we gracefully handle them @@ -133,13 +145,13 @@ def test_plugin_clickatell_edge_cases(mock_post): # Prepare Mock mock_post.return_value = response - # Initialize some generic (but valid) tokens - api_token = 'b' * 32 + # Initialize some generic (but valid) apikeys + apikey = 'b' * 32 from_phone = '+1 (555) 123-3456' - # No api_token specified + # No apikey specified with pytest.raises(TypeError): - NotifyClickatell(api_token=None, from_phone=from_phone) + NotifyClickatell(apikey=None, from_phone=from_phone) # a error response response.status_code = 400 @@ -150,7 +162,7 @@ def test_plugin_clickatell_edge_cases(mock_post): mock_post.return_value = response # Initialize our object - obj = NotifyClickatell(api_token=api_token, from_phone=from_phone) + obj = NotifyClickatell(apikey=apikey, from_phone=from_phone) # We will fail with the above error code assert obj.notify('title', 'body', 'info') is False