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 Chanify
Chat Chat
CLI CLI
Clickatell
ClickSend ClickSend
D7Networks D7Networks
Dapnet 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/ | [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/ | [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/ | [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 | [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 | [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 | [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: it easy to access:
Africas Talking, Apprise API, APRS, AWS SES, AWS SNS, Bark, BlueSky, Burst SMS, 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 FCM, Feishu, Flock, Free Mobile, Google Chat, Gotify, Growl, Guilded, Home
Assistant, httpSMS, IFTTT, Join, Kavenegar, KODI, Kumulos, LaMetric, Line, Assistant, httpSMS, IFTTT, Join, Kavenegar, KODI, Kumulos, LaMetric, Line,
LunaSea, MacOSX, Mailgun, Mastodon, Mattermost,Matrix, MessageBird, Microsoft 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