Adds Clickatell support (#1347)

This commit is contained in:
Diego Pedregal 2025-06-12 04:19:44 +02:00 committed by GitHub
parent 70cb7d8c11
commit cf12873be2
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 471 additions and 1 deletions

View File

@ -11,6 +11,7 @@ Burst SMS
Chanify
Chat
CLI
Clickatell
ClickSend
D7Networks
Dapnet

View File

@ -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<br/>bulksms://User:Password@ToPhoneNo1/ToPhoneNo2/ToPhoneNoN/
| [BulkVS](https://github.com/caronc/apprise/wiki/Notify_bulkvs) | bulkvs:// | (TCP) 443 | bulkvs://user:password@FromPhoneNo<br/>bulkvs://user:password@FromPhoneNo/ToPhoneNo<br/>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<br/>burstsms://ApiKey:ApiSecret@FromPhoneNo/ToPhoneNo1/ToPhoneNo2/ToPhoneNoN/
| [Clickatell](https://github.com/caronc/apprise/wiki/Notify_clickatell) | clickatell:// | (TCP) 443 | clickatell://ApiKey/ToPhoneNo<br/>clickatell://FromPhoneNo@ApiKey/ToPhoneNo1/ToPhoneNo2/ToPhoneNoN
| [ClickSend](https://github.com/caronc/apprise/wiki/Notify_clicksend) | clicksend:// | (TCP) 443 | clicksend://user:pass@PhoneNo<br/>clicksend://user:pass@ToPhoneNo1/ToPhoneNo2/ToPhoneNoN
| [DAPNET](https://github.com/caronc/apprise/wiki/Notify_dapnet) | dapnet:// | (TCP) 80 | dapnet://user:pass@callsign<br/>dapnet://user:pass@callsign1/callsign2/callsignN
| [D7 Networks](https://github.com/caronc/apprise/wiki/Notify_d7networks) | d7sms:// | (TCP) 443 | d7sms://token@PhoneNo<br/>d7sms://token@ToPhoneNo1/ToPhoneNo2/ToPhoneNoN

View File

@ -0,0 +1,300 @@
# -*- coding: utf-8 -*-
# BSD 2-Clause License
#
# Apprise - Push Notification Library.
# Copyright (c) 2025, Chris Caron <lead2gold@gmail.com>
#
# 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

View File

@ -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

View File

@ -0,0 +1,168 @@
# -*- coding: utf-8 -*-
# BSD 2-Clause License
#
# Apprise - Push Notification Library.
# Copyright (c) 2025, Chris Caron <lead2gold@gmail.com>
#
# 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