From 03295517edbf6b80c7e14bfd2ef3aa8a9344ada8 Mon Sep 17 00:00:00 2001 From: Chris Caron Date: Fri, 6 Sep 2019 19:41:23 -0400 Subject: [PATCH] ClickSend Support (#145) --- README.md | 1 + apprise/plugins/NotifyClickSend.py | 328 +++++++++++++++++++++++++++ packaging/redhat/python-apprise.spec | 4 +- setup.py | 12 +- test/test_rest_plugins.py | 41 ++++ 5 files changed, 378 insertions(+), 8 deletions(-) create mode 100644 apprise/plugins/NotifyClickSend.py diff --git a/README.md b/README.md index f00efa95..59d53864 100644 --- a/README.md +++ b/README.md @@ -73,6 +73,7 @@ The table below identifies the services this tool supports and some example serv | Notification Service | Service ID | Default Port | Example Syntax | | -------------------- | ---------- | ------------ | -------------- | | [AWS SNS](https://github.com/caronc/apprise/wiki/Notify_sns) | sns:// | (TCP) 443 | sns://AccessKeyID/AccessSecretKey/RegionName/+PhoneNo
sns://AccessKeyID/AccessSecretKey/RegionName/+PhoneNo1/+PhoneNo2/+PhoneNoN
sns://AccessKeyID/AccessSecretKey/RegionName/Topic
sns://AccessKeyID/AccessSecretKey/RegionName/Topic1/Topic2/TopicN +| [ClickSend](https://github.com/caronc/apprise/wiki/Notify_clicksend) | clicksend:// | (TCP) 443 | clicksend://user:pass@PhoneNo
clicksend://user:pass@ToPhoneNo1/ToPhoneNo2/ToPhoneNoN | [D7 Networks](https://github.com/caronc/apprise/wiki/Notify_d7networks) | d7sms:// | (TCP) 443 | d7sms://user:pass@PhoneNo
d7sms://user:pass@ToPhoneNo1/ToPhoneNo2/ToPhoneNoN | [MessageBird](https://github.com/caronc/apprise/wiki/Notify_messagebird) | msgbird:// | (TCP) 443 | msgbird://ApiKey/FromPhoneNo
msgbird://ApiKey/FromPhoneNo/ToPhoneNo
msgbird://ApiKey/FromPhoneNo/ToPhoneNo1/ToPhoneNo2/ToPhoneNoN/ | [MSG91](https://github.com/caronc/apprise/wiki/Notify_msg91) | msg91:// | (TCP) 443 | msg91://AuthKey/ToPhoneNo
msg91://SenderID@AuthKey/ToPhoneNo
msg91://AuthKey/ToPhoneNo1/ToPhoneNo2/ToPhoneNoN/ diff --git a/apprise/plugins/NotifyClickSend.py b/apprise/plugins/NotifyClickSend.py new file mode 100644 index 00000000..99417614 --- /dev/null +++ b/apprise/plugins/NotifyClickSend.py @@ -0,0 +1,328 @@ +# -*- 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 plugin, simply signup with clicksend: +# https://www.clicksend.com/ +# +# You're done at this point, you only need to know your user/pass that +# you signed up with. + +# The following URLs would be accepted by Apprise: +# - clicksend://{user}:{password}@{phoneno} +# - clicksend://{user}:{password}@{phoneno1}/{phoneno2} + +# The API reference used to build this plugin was documented here: +# https://developers.clicksend.com/docs/rest/v3/ +# +import re +import requests +from json import dumps +from base64 import b64encode + +from .NotifyBase import NotifyBase +from ..common import NotifyType +from ..utils import parse_list +from ..utils import parse_bool +from ..AppriseLocale import gettext_lazy as _ + +# Extend HTTP Error Messages +CLICKSEND_HTTP_ERROR_MAP = { + 401: 'Unauthorized - Invalid Token.', +} + +# Some Phone Number Detection +IS_PHONE_NO = re.compile(r'^\+?(?P[0-9\s)(+-]+)\s*$') + +# Used to break path apart into list of channels +TARGET_LIST_DELIM = re.compile(r'[ \t\r\n,#\\/]+') + + +class NotifyClickSend(NotifyBase): + """ + A wrapper for ClickSend Notifications + """ + + # The default descriptive name associated with the Notification + service_name = 'ClickSend' + + # The services URL + service_url = 'https://clicksend.com/' + + # The default secure protocol + secure_protocol = 'clicksend' + + # A URL that takes you to the setup/help of the specific protocol + setup_url = 'https://github.com/caronc/apprise/wiki/Notify_clicksend' + + # ClickSend uses the http protocol with JSON requests + notify_url = 'https://rest.clicksend.com/v3/sms/send' + + # The maximum length of the body + body_maxlen = 160 + + # 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 + + # The maximum SMS batch size accepted by the ClickSend API + sms_batch_size = 1000 + + # Define object templates + templates = ( + '{schema}://{user}:{password}@{targets}', + ) + + # Define our template tokens + template_tokens = dict(NotifyBase.template_tokens, **{ + 'user': { + 'name': _('User Name'), + 'type': 'string', + 'required': True, + }, + 'password': { + 'name': _('Password'), + 'type': 'string', + 'private': True, + 'required': True, + }, + '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, + }, + }) + + # Define our template arguments + template_args = dict(NotifyBase.template_args, **{ + 'to': { + 'alias_of': 'targets', + }, + 'batch': { + 'name': _('Batch Mode'), + 'type': 'bool', + 'default': False, + }, + }) + + def __init__(self, targets=None, batch=False, **kwargs): + """ + Initialize ClickSend Object + """ + super(NotifyClickSend, self).__init__(**kwargs) + + # Prepare Batch Mode Flag + self.batch = batch + + # Parse our targets + self.targets = list() + + if not (self.user and self.password): + msg = 'A ClickSend user/pass was not provided.' + self.logger.warning(msg) + raise TypeError(msg) + + 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 + 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(result) + continue + + self.logger.warning( + 'Dropped invalid phone # ' + '({}) specified.'.format(target)) + + def send(self, body, title='', notify_type=NotifyType.INFO, **kwargs): + """ + Perform ClickSend Notification + """ + + if len(self.targets) == 0: + # There were no services to notify + self.logger.warning('There were no ClickSend targets to notify.') + return False + + headers = { + 'User-Agent': self.app_id, + 'Content-Type': 'application/json; charset=utf-8', + 'Authorization': 'Basic {}'.format( + b64encode('{}:{}'.format( + self.user, self.password).encode('utf-8'))), + } + + # error tracking (used for function return) + has_error = False + + # prepare JSON Object + payload = { + 'messages': [] + } + + # Create a copy of the target list + targets = list(self.targets) + + # Send in batches if identified to do so + sms_batch_size = 1 if not self.batch else self.sms_batch_size + + for index in range(0, len(targets), sms_batch_size): + payload['messages'] = [{ + 'source': 'php', + 'body': body, + 'to': '+{}'.format(to), + } for to in targets[index:index + sms_batch_size]] + + self.logger.debug('ClickSend POST URL: %s (cert_verify=%r)' % ( + self.notify_url, self.verify_certificate, + )) + self.logger.debug('ClickSend Payload: %s' % str(payload)) + + # Always call throttle before any remote server i/o is made + self.throttle() + try: + r = requests.post( + self.notify_url, + data=dumps(payload), + headers=headers, + verify=self.verify_certificate, + ) + if r.status_code != requests.codes.ok: + # We had a problem + status_str = \ + NotifyClickSend.http_response_code_lookup( + r.status_code, CLICKSEND_HTTP_ERROR_MAP) + + self.logger.warning( + 'Failed to send {} ClickSend notification{}: ' + '{}{}error={}.'.format( + len(payload['messages']), + ' to {}'.format(targets[index]) + if sms_batch_size == 1 else '(s)', + 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 {} ClickSend notification{}.' + .format( + len(payload['messages']), + ' to {}'.format(targets[index]) + if sms_batch_size == 1 else '(s)', + )) + + except requests.RequestException as e: + self.logger.warning( + 'A Connection error occured sending {} ClickSend ' + 'notification(s).'.format(len(payload['messages']))) + 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', + 'batch': 'yes' if self.batch else 'no', + } + + # Setup Authentication + auth = '{user}:{password}@'.format( + user=NotifyClickSend.quote(self.user, safe=''), + password=NotifyClickSend.quote(self.password, safe=''), + ) + + return '{schema}://{auth}{targets}?{args}'.format( + schema=self.secure_protocol, + auth=auth, + targets='/'.join( + [NotifyClickSend.quote(x, safe='') for x in self.targets]), + args=NotifyClickSend.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 + + # All elements are targets + results['targets'] = [NotifyClickSend.unquote(results['host'])] + + # All entries after the hostname are additional targets + results['targets'].extend( + NotifyClickSend.split_path(results['fullpath'])) + + # Get Batch Mode Flag + results['batch'] = \ + parse_bool(results['qsd'].get('batch', False)) + + # Support the 'to' variable so that we can support rooms this way too + # The 'to' makes it easier to use yaml configuration + if 'to' in results['qsd'] and len(results['qsd']['to']): + results['targets'] += [x for x in filter( + bool, TARGET_LIST_DELIM.split( + NotifyClickSend.unquote(results['qsd']['to'])))] + + return results diff --git a/packaging/redhat/python-apprise.spec b/packaging/redhat/python-apprise.spec index 74872d00..03f4b88e 100644 --- a/packaging/redhat/python-apprise.spec +++ b/packaging/redhat/python-apprise.spec @@ -47,8 +47,8 @@ Apprise is a Python package for simplifying access to all of the different notification services that are out there. Apprise opens the door and makes it easy to access: -Boxcar, Discord, E-Mail, Emby, Faast, Flock, Gitter, Gotify, Growl, IFTTT, -Join, KODI, Mailgun, MatterMost, Matrix, Microsoft Windows Notifications, +Boxcar, ClickSend, Discord, E-Mail, Emby, Faast, Flock, Gitter, Gotify, Growl, +IFTTT, Join, KODI, Mailgun, MatterMost, Matrix, Microsoft Windows Notifications, Microsoft Teams, MessageBird, MSG91, Nexmo, Notify MyAndroid, Prowl, Pushalot, PushBullet, Pushjet, Pushover, Rocket.Chat, SendGrid, Slack, Super Toasty, Stride, Techulus Push, Telegram, Twilio, Twitter, Twist, XBMC, XMPP, diff --git a/setup.py b/setup.py index 0c6fea28..f79c1712 100755 --- a/setup.py +++ b/setup.py @@ -69,12 +69,12 @@ setup( long_description_content_type='text/markdown', cmdclass=cmdclass, url='https://github.com/caronc/apprise', - keywords='Push Notifications Alerts Email AWS SNS Boxcar Discord Dbus ' - 'Emby Faast Flock Gitter Gnome Gotify Growl IFTTT Join KODI Mailgun ' - 'Matrix Mattermost MessageBird MSG91 Nexmo Prowl PushBullet Pushjet ' - 'Pushed Pushover Rocket.Chat Ryver SendGrid Slack Stride ' - 'Techulus Push Telegram Twilio Twist Twitter XBMC Microsoft MSTeams ' - 'Windows Webex CLI API', + keywords='Push Notifications Alerts Email AWS SNS Boxcar ClickSend ' + 'Discord Dbus Emby Faast Flock Gitter Gnome Gotify Growl IFTTT Join ' + 'KODI Mailgun Matrix Mattermost MessageBird MSG91 Nexmo Prowl ' + 'PushBullet Pushjet Pushed Pushover Rocket.Chat Ryver SendGrid Slack ' + 'Stride Techulus Push Telegram Twilio Twist 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 89355f35..38688de9 100644 --- a/test/test_rest_plugins.py +++ b/test/test_rest_plugins.py @@ -128,6 +128,47 @@ TEST_URLS = ( 'test_requests_exceptions': True, }), + ################################## + # NotifyClickSend + ################################## + ('clicksend://', { + # No authentication + 'instance': TypeError, + }), + ('clicksend://:@/', { + # invalid user/pass + 'instance': TypeError, + }), + ('clicksend://user:pass@{}/{}/{}'.format('1' * 10, '2' * 15, 'a' * 13), { + # invalid target numbers; we'll fail to notify anyone + 'instance': plugins.NotifyClickSend, + 'notify_response': False, + }), + ('clicksend://user:pass@{}?batch=yes'.format('3' * 14), { + # valid number + 'instance': plugins.NotifyClickSend, + }), + ('clicksend://user:pass@{}?batch=yes&to={}'.format('3' * 14, '6' * 14), { + # valid number but using the to= variable + 'instance': plugins.NotifyClickSend, + }), + ('clicksend://user:pass@{}?batch=no'.format('3' * 14), { + # valid number - no batch + 'instance': plugins.NotifyClickSend, + }), + ('clicksend://user:pass@{}'.format('3' * 14), { + 'instance': plugins.NotifyClickSend, + # throw a bizzare code forcing us to fail to look it up + 'response': False, + 'requests_response_code': 999, + }), + ('clicksend://user:pass@{}'.format('3' * 14), { + 'instance': plugins.NotifyClickSend, + # Throws a series of connection and transfer exceptions when this flag + # is set and tests that we gracfully handle them + 'test_requests_exceptions': True, + }), + ################################## # NotifyD7Networks ##################################