diff --git a/KEYWORDS b/KEYWORDS index 63c8452c..52ca10a2 100644 --- a/KEYWORDS +++ b/KEYWORDS @@ -11,6 +11,7 @@ Burst SMS Chanify Chat CLI +Clickatell ClickSend D7Networks Dapnet diff --git a/README.md b/README.md index ad404d8a..6059039e 100644 --- a/README.md +++ b/README.md @@ -151,6 +151,7 @@ SMS Notifications for the most part do not have a both a `title` and `body`. Th | [BulkSMS](https://github.com/caronc/apprise/wiki/Notify_bulksms) | bulksms:// | (TCP) 443 | bulksms://user:password@ToPhoneNo
bulksms://User:Password@ToPhoneNo1/ToPhoneNo2/ToPhoneNoN/ | [BulkVS](https://github.com/caronc/apprise/wiki/Notify_bulkvs) | bulkvs:// | (TCP) 443 | bulkvs://user:password@FromPhoneNo
bulkvs://user:password@FromPhoneNo/ToPhoneNo
bulkvs://user:password@FromPhoneNo/ToPhoneNo1/ToPhoneNo2/ToPhoneNoN/ | [Burst SMS](https://github.com/caronc/apprise/wiki/Notify_burst_sms) | burstsms:// | (TCP) 443 | burstsms://ApiKey:ApiSecret@FromPhoneNo/ToPhoneNo
burstsms://ApiKey:ApiSecret@FromPhoneNo/ToPhoneNo1/ToPhoneNo2/ToPhoneNoN/ +| [Clickatell](https://github.com/caronc/apprise/wiki/Notify_clickatell) | clickatell:// | (TCP) 443 | clickatell://ApiKey/ToPhoneNo
clickatell://FromPhoneNo@ApiKey/ToPhoneNo1/ToPhoneNo2/ToPhoneNoN | [ClickSend](https://github.com/caronc/apprise/wiki/Notify_clicksend) | clicksend:// | (TCP) 443 | clicksend://user:pass@PhoneNo
clicksend://user:pass@ToPhoneNo1/ToPhoneNo2/ToPhoneNoN | [DAPNET](https://github.com/caronc/apprise/wiki/Notify_dapnet) | dapnet:// | (TCP) 80 | dapnet://user:pass@callsign
dapnet://user:pass@callsign1/callsign2/callsignN | [D7 Networks](https://github.com/caronc/apprise/wiki/Notify_d7networks) | d7sms:// | (TCP) 443 | d7sms://token@PhoneNo
d7sms://token@ToPhoneNo1/ToPhoneNo2/ToPhoneNoN diff --git a/apprise/plugins/clickatell.py b/apprise/plugins/clickatell.py new file mode 100644 index 00000000..944ea588 --- /dev/null +++ b/apprise/plugins/clickatell.py @@ -0,0 +1,300 @@ +# -*- coding: utf-8 -*- +# BSD 2-Clause License +# +# Apprise - Push Notification Library. +# Copyright (c) 2025, Chris Caron +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# +# 1. Redistributions of source code must retain the above copyright notice, +# this list of conditions and the following disclaimer. +# +# 2. Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions and the following disclaimer in the documentation +# and/or other materials provided with the distribution. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE +# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +# POSSIBILITY OF SUCH DAMAGE. + +from itertools import chain + +# 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 .base import NotifyBase +from ..common import NotifyType +from ..locale import gettext_lazy as _ +from ..utils.parse import is_phone_no, validate_regex, parse_phone_no + + +class NotifyClickatell(NotifyBase): + """ + A wrapper for Clickatell Notifications + """ + + # The default descriptive name associated with the Notification + service_name = _('Clickatell') + + # The services URL + service_url = 'https://www.clickatell.com/' + + # All notification requests are secure + secure_protocol = 'clickatell' + + # A URL that takes you to the setup/help of the specific protocol + setup_url = 'https://github.com/caronc/apprise/wiki/Notify_clickatell' + + # Clickatell API Endpoint + notify_url = 'https://platform.clickatell.com/messages/http/send' + + # 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 + + templates = ( + '{schema}://{apikey}/{targets}', + '{schema}://{source}@{apikey}/{targets}', + ) + + template_tokens = dict(NotifyBase.template_tokens, **{ + 'apikey': { + 'name': _('API Token'), + 'type': 'string', + 'private': True, + 'required': True, + }, + 'source': { + 'name': _('From Phone No'), + 'type': 'string', + 'regex': (r'^[0-9\s)(+-]+$', 'i'), + }, + 'target_phone': { + 'name': _('Target Phone No'), + 'type': 'string', + 'prefix': '+', + 'regex': (r'^[0-9\s)(+-]+$', 'i'), + 'map_to': 'targets', + }, + 'targets': { + 'name': _('Targets'), + 'type': 'list:string', + 'required': True, + }, + }) + + template_args = dict(NotifyBase.template_args, **{ + 'apikey': { + 'alias_of': 'apikey' + }, + 'to': { + 'alias_of': 'targets', + }, + 'from': { + 'alias_of': 'source', + }, + }) + + def __init__(self, apikey, source=None, targets=None, **kwargs): + """ + Initialize Clickatell Object + """ + + super().__init__(**kwargs) + + self.apikey = validate_regex(apikey) + if not self.apikey: + msg = 'An invalid Clickatell API Token ' \ + '({}) was specified.'.format(apikey) + self.logger.warning(msg) + raise TypeError(msg) + + 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): + """ + Returns the URL built dynamically based on specified arguments. + """ + + params = self.url_parameters(privacy=privacy, *args, **kwargs) + + return '{schema}://{source}{apikey}/{targets}/?{params}'.format( + schema=self.secure_protocol, + 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 + """ + + if not self.targets: + # There were no targets to notify + self.logger.warning( + 'There were no Clickatell targets to notify') + return False + + headers = { + 'User-Agent': self.app_id, + 'Accept': 'application/json', + 'Content-Type': 'application/json', + } + + params_base = { + 'apiKey': self.apikey, + 'from': self.source, + 'content': body, + } + + # error tracking (used for function return) + has_error = False + + for target in self.targets: + params = params_base.copy() + params['to'] = target + + # Some Debug Logging + self.logger.debug('Clickatell GET URL: {} (cert_verify={})'.format( + self.notify_url, self.verify_certificate)) + self.logger.debug('Clickatell Payload: {}' .format(params)) + + # Always call throttle before any remote server i/o is made + self.throttle() + + try: + r = requests.get( + self.notify_url, + params=params, + headers=headers, + verify=self.verify_certificate, + timeout=self.request_timeout, + ) + + if r.status_code != requests.codes.ok \ + and r.status_code != requests.codes.accepted: + # We had a problem + status_str = self.http_response_code_lookup(r.status_code) + + self.logger.warning( + 'Failed to send Clickatell notification: ' + '{}{}error={}.'.format( + status_str, + ', ' if status_str else '', + r.status_code)) + + self.logger.debug( + 'Response Details:\r\n{}'.format(r.content)) + # Mark our failure + has_error = True + continue + + else: + self.logger.info( + 'Sent Clickatell notification to %s', target) + + except requests.RequestException as e: + self.logger.warning( + 'A Connection error occurred sending Clickatell: to %s ', + target) + self.logger.debug('Socket Exception: %s' % str(e)) + # Mark our failure + has_error = True + continue + + return not has_error + + @staticmethod + def parse_url(url): + """ + Parses the URL and returns enough arguments that can allow + us to re-instantiate this object. + """ + results = NotifyBase.parse_url(url, verify_host=False) + if not results: + # We're done early as we couldn't parse the URL + return results + + results['targets'] = NotifyClickatell.split_path(results['fullpath']) + results['apikey'] = NotifyClickatell.unquote(results['host']) + + if results['user']: + 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/packaging/redhat/python-apprise.spec b/packaging/redhat/python-apprise.spec index 9ca222f8..a396da89 100644 --- a/packaging/redhat/python-apprise.spec +++ b/packaging/redhat/python-apprise.spec @@ -40,7 +40,7 @@ notification services that are out there. Apprise opens the door and makes it easy to access: Africas Talking, Apprise API, APRS, AWS SES, AWS SNS, Bark, BlueSky, Burst SMS, -BulkSMS, BulkVS, Chanify, ClickSend, DAPNET, DingTalk, Discord, E-Mail, Emby, +BulkSMS, BulkVS, Chanify, Clickatell, ClickSend, DAPNET, DingTalk, Discord, E-Mail, Emby, FCM, Feishu, Flock, Free Mobile, Google Chat, Gotify, Growl, Guilded, Home Assistant, httpSMS, IFTTT, Join, Kavenegar, KODI, Kumulos, LaMetric, Line, LunaSea, MacOSX, Mailgun, Mastodon, Mattermost,Matrix, MessageBird, Microsoft diff --git a/test/test_plugin_clickatell.py b/test/test_plugin_clickatell.py new file mode 100644 index 00000000..90ae2894 --- /dev/null +++ b/test/test_plugin_clickatell.py @@ -0,0 +1,168 @@ +# -*- coding: utf-8 -*- +# BSD 2-Clause License +# +# Apprise - Push Notification Library. +# Copyright (c) 2025, Chris Caron +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# +# 1. Redistributions of source code must retain the above copyright notice, +# this list of conditions and the following disclaimer. +# +# 2. Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions and the following disclaimer in the documentation +# and/or other materials provided with the distribution. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE +# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +# POSSIBILITY OF SUCH DAMAGE. + +import logging +from json import dumps +from unittest import mock + +import pytest +import requests + +from apprise.plugins.clickatell import NotifyClickatell +from helpers import AppriseURLTester + +logging.disable(logging.CRITICAL) + +# Our Testing URLs +apprise_url_tests = ( + ('clickatell://', { + # only schema provided + 'instance': TypeError, + }), + ('clickatell:///', { + # invalid apikey + 'instance': TypeError, + }), + ('clickatell://@/', { + # 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': NotifyClickatell, + # We have no one to notify + 'notify_response': False, + }), + ('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( + '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' * 10), { + # everything valid (no source) + 'instance': NotifyClickatell, + }), + ('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://_?apikey={}&from={}'.format('a' * 32, '1' * 10), { + # use get args + 'instance': NotifyClickatell, + 'notify_response': False, + }), + ('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('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 + 'test_requests_exceptions': True, + }), +) + + +def test_plugin_clickatell_urls(): + """ + NotifyClickatell() Apprise URLs + """ + + # Run our general tests + AppriseURLTester(tests=apprise_url_tests).run_all() + + +@mock.patch('requests.post') +def test_plugin_clickatell_edge_cases(mock_post): + """ + NotifyClickatell() Edge Cases + """ + + # Prepare our response + response = requests.Request() + response.status_code = requests.codes.ok + + # Prepare Mock + mock_post.return_value = response + + # Initialize some generic (but valid) apikeys + apikey = 'b' * 32 + from_phone = '+1 (555) 123-3456' + + # No apikey specified + with pytest.raises(TypeError): + NotifyClickatell(apikey=None, from_phone=from_phone) + + # 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 = NotifyClickatell(apikey=apikey, from_phone=from_phone) + + # We will fail with the above error code + assert obj.notify('title', 'body', 'info') is False