From 56bcbf5f45e9395b6135b9fa851820f88a2725e1 Mon Sep 17 00:00:00 2001 From: Chris Caron Date: Sat, 14 Sep 2024 20:00:47 -0400 Subject: [PATCH] Plivo Support (#143) --- KEYWORDS | 1 + README.md | 1 + apprise/plugins/plivo.py | 405 +++++++++++++++++++++++++++ packaging/redhat/python-apprise.spec | 14 +- setup.py | 1 + test/test_plugin_plivo.py | 118 ++++++++ 6 files changed, 533 insertions(+), 7 deletions(-) create mode 100644 apprise/plugins/plivo.py create mode 100644 test/test_plugin_plivo.py diff --git a/KEYWORDS b/KEYWORDS index d624638f..6ce9ca9d 100644 --- a/KEYWORDS +++ b/KEYWORDS @@ -63,6 +63,7 @@ Opsgenie PagerDuty PagerTree ParsePlatform +Plivo PopcornNotify Power Automate Prowl diff --git a/README.md b/README.md index 824bf25e..fa216116 100644 --- a/README.md +++ b/README.md @@ -157,6 +157,7 @@ The table below identifies the services this tool supports and some example serv | [Kavenegar](https://github.com/caronc/apprise/wiki/Notify_kavenegar) | kavenegar:// | (TCP) 443 | kavenegar://ApiKey/ToPhoneNo
kavenegar://FromPhoneNo@ApiKey/ToPhoneNo
kavenegar://ApiKey/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://TemplateID@AuthKey/ToPhoneNo
msg91://TemplateID@AuthKey/ToPhoneNo1/ToPhoneNo2/ToPhoneNoN/ +| [Plivo](https://github.com/caronc/apprise/wiki/Notify_plivo) | plivo:// | (TCP) 443 | plivo://AuthID@Token@FromPhoneNo
plivo://AuthID@Token/FromPhoneNo/ToPhoneNo
plivo://AuthID@Token/FromPhoneNo/ToPhoneNo1/ToPhoneNo2/ToPhoneNoN/ | [Société Française du Radiotéléphone (SFR)](https://github.com/caronc/apprise/wiki/Notify_sfr) | sfr:// | (TCP) 443 | sfr://user:password>@spaceId/ToPhoneNo
sfr://user:password>@spaceId/ToPhoneNo1/ToPhoneNo2/ToPhoneNoN/ | [Signal API](https://github.com/caronc/apprise/wiki/Notify_signal) | signal:// or signals:// | (TCP) 80 or 443 | signal://hostname:port/FromPhoneNo
signal://hostname:port/FromPhoneNo/ToPhoneNo
signal://hostname:port/FromPhoneNo/ToPhoneNo1/ToPhoneNo2/ToPhoneNoN/ | [Sinch](https://github.com/caronc/apprise/wiki/Notify_sinch) | sinch:// | (TCP) 443 | sinch://ServicePlanId:ApiToken@FromPhoneNo
sinch://ServicePlanId:ApiToken@FromPhoneNo/ToPhoneNo
sinch://ServicePlanId:ApiToken@FromPhoneNo/ToPhoneNo1/ToPhoneNo2/ToPhoneNoN/
sinch://ServicePlanId:ApiToken@ShortCode/ToPhoneNo
sinch://ServicePlanId:ApiToken@ShortCode/ToPhoneNo1/ToPhoneNo2/ToPhoneNoN/ diff --git a/apprise/plugins/plivo.py b/apprise/plugins/plivo.py new file mode 100644 index 00000000..d4c97ef3 --- /dev/null +++ b/apprise/plugins/plivo.py @@ -0,0 +1,405 @@ +# -*- coding: utf-8 -*- +# BSD 3-Clause License +# +# Apprise - Push Notification Library. +# Copyright (c) 2023, 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. +# +# 3. Neither the name of the copyright holder nor the names of its +# contributors may be used to endorse or promote products derived from +# this software without specific prior written permission. +# +# 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. + +# Create an account https://messagebird.com if you don't already have one +# +# Get your auth_id and auth token from the dashboard here: +# - https://console.plivo.com/dashboard/ +# + +import requests + +from json import dumps +from .base import NotifyBase +from ..common import NotifyType +from ..utils import parse_bool +from ..utils import is_phone_no +from ..utils import parse_phone_no +from ..utils import validate_regex +from ..locale import gettext_lazy as _ + + +class NotifyPlivo(NotifyBase): + """ + A wrapper for Plivo Notifications + """ + + # The default descriptive name associated with the Notification + service_name = 'Plivo' + + # The services URL + service_url = 'https://plivo.com' + + # The default protocol + secure_protocol = 'plivo' + + # A URL that takes you to the setup/help of the specific protocol + setup_url = 'https://github.com/caronc/apprise/wiki/Notify_plivo' + + # Plivo uses the http protocol with JSON requests + notify_url = 'https://api.plivo.com/v1/Account/{auth_id}/Message/' + + # The maximum number of messages that can be sent in a single batch + default_batch_size = 20 + + # The maximum length of the body + body_maxlen = 140 + + # 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 + + # Define object templates + templates = ( + '{schema}://{auth_id}@{token}/{source}', + '{schema}://{auth_id}@{token}/{source}/{targets}', + ) + + # Define our template tokens + template_tokens = dict(NotifyBase.template_tokens, **{ + 'auth_id': { + 'name': _('Auth ID'), + 'type': 'string', + 'required': True, + 'regex': (r'^[a-z0-9]{20,30}$', 'i'), + }, + 'token': { + 'name': _('Auth Token'), + 'type': 'string', + 'required': True, + 'regex': (r'^[a-z0-9]{30,50}$', 'i'), + }, + 'source': { + 'name': _('Source Phone No'), + 'type': 'string', + 'prefix': '+', + 'required': True, + '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', + } + }) + + # Define our template arguments + template_args = dict(NotifyBase.template_args, **{ + 'to': { + 'alias_of': 'targets', + }, + 'from': { + 'alias_of': 'source', + }, + 'token': { + 'alias_of': 'token', + }, + 'id': { + 'alias_of': 'auth_id', + }, + 'batch': { + 'name': _('Batch Mode'), + 'type': 'bool', + 'default': False, + }, + }) + + def __init__(self, auth_id, token, source, targets=None, batch=None, + **kwargs): + """ + Initialize Plivo Object + """ + super(NotifyPlivo, self).__init__(**kwargs) + + self.auth_id = validate_regex( + auth_id, *self.template_tokens['auth_id']['regex']) + if not self.auth_id: + msg = 'The Plivo authentication ID specified ({}) is ' \ + 'invalid.'.format(auth_id) + self.logger.warning(msg) + raise TypeError(msg) + + self.token = validate_regex( + token, *self.template_tokens['token']['regex']) + if not self.token: + msg = 'The Plivo authentication token specified ({}) is ' \ + 'invalid.'.format(token) + self.logger.warning(msg) + raise TypeError(msg) + + result = is_phone_no(source) + if not result: + msg = 'The Plivo source specified ({}) is invalid.'\ + .format(source) + self.logger.warning(msg) + raise TypeError(msg) + + # Store our source; enforce E.164 format + self.source = f'+{result["full"]}' + + # Parse our targets + self.targets = list() + + if targets: + for target in parse_phone_no(targets): + # Validate targets and drop bad ones: + result = is_phone_no(target) + if result: + # store valid phone number; enforce E.164 format + self.targets.append(f'+{result["full"]}') + continue + + self.logger.warning( + 'Dropped invalid phone # ' + '({}) specified.'.format(target), + ) + else: + # No sources specified, use our own phone no + self.targets.append(self.source) + + # Set batch + self.batch = batch if batch is not None \ + else self.template_args['batch']['default'] + + def send(self, body, title='', notify_type=NotifyType.INFO, **kwargs): + """ + Perform Plivo Notification + """ + + if not self.targets: + # There were no services to notify + self.logger.warning( + 'There were no Plivo targets to notify.') + return False + + # Initialize our has_error flag + has_error = False + + # Prepare our headers + headers = { + 'User-Agent': self.app_id, + 'Content-Type': 'application/json', + } + + # Prepare our authentication + auth = (self.auth_id, self.token) + + # Prepare our payload + payload = { + 'src': self.source, + 'dst': None, + 'text': body, + } + + # Send in batches if identified to do so + batch_size = 1 if not self.batch else self.default_batch_size + + for index in range(0, len(self.targets), batch_size): + # Prepare our phone no (< delimits more then one) + payload['recipients'] = \ + ','.join(self.targets[index:index + batch_size]) + + # Some Debug Logging + self.logger.debug( + 'Plivo POST URL: {} (cert_verify={})'.format( + self.notify_url, self.verify_certificate)) + self.logger.debug('Plivo Payload: {}' .format(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, + auth=auth, + verify=self.verify_certificate, + timeout=self.request_timeout, + ) + + if r.status_code not in ( + requests.codes.ok, requests.codes.accepted): + # We had a problem + status_str = \ + NotifyPlivo.http_response_code_lookup( + r.status_code) + + self.logger.warning( + 'Failed to send {} Plivo notification{}: ' + '{}{}error={}.'.format( + len(self.targets[index:index + batch_size]), + ' to {}'.format(self.targets[index]) + if 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( + 'Send {} Plivo notification{}'.format( + len(self.targets[index:index + batch_size]), + ' to {}'.format(self.targets[index]) + if batch_size == 1 else '(s)', + )) + + except requests.RequestException as e: + self.logger.warning( + 'A Connection error occured sending Plivo:%s ' % ( + self.targets) + 'notification.' + ) + self.logger.debug('Socket Exception: %s' % str(e)) + + # Mark our failure + has_error = True + continue + + return not has_error + + @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.secure_protocol if self.secure else self.protocol, + self.auth_id, self.token, self.source, + ) + + def url(self, privacy=False, *args, **kwargs): + """ + Returns the URL built dynamically based on specified arguments. + """ + + # Define any arguments set + params = { + 'batch': 'yes' if self.batch else 'no', + } + + # Extend our parameters + params.update(self.url_parameters(privacy=privacy, *args, **kwargs)) + + return '{schema}://{auth_id}@{token}/{source}/' \ + '{targets}/?{params}'.format( + schema=self.secure_protocol, + auth_id=self.pprint(self.auth_id, privacy, safe=''), + token=self.pprint(self.token, privacy, safe=''), + source=self.source, + targets='/'.join( + [NotifyPlivo.quote(x, safe='+') for x in self.targets]), + params=NotifyPlivo.urlencode(params)) + + def __len__(self): + """ + Returns the number of targets associated with this notification + """ + # + # Factor batch into calculation + # + return len(self.targets) if self.targets else 1 + + @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 + + # The Auth ID is in the username field + if 'id' in results['qsd'] and len(results['qsd']['id']): + results['auth_id'] = NotifyPlivo.unquote(results['qsd']['id']) + + else: + results['auth_id'] = NotifyPlivo.unquote(results['user']) + + # Get our entries; split_path() looks after unquoting content for us + # by default + results['targets'] = NotifyPlivo.split_path(results['fullpath']) + if 'token' in results['qsd'] and len(results['qsd']['token']): + # Store token + results['token'] = NotifyPlivo.unquote(results['qsd']['token']) + + # go ahead and put the host entry in the targets list + if results['host']: + results['targets'].insert( + 0, NotifyPlivo.unquote(results['host'])) + + else: + # The hostname is our authentication key + results['token'] = NotifyPlivo.unquote(results['host']) + + if 'from' in results['qsd'] and len(results['qsd']['from']): + results['source'] = \ + NotifyPlivo.unquote(results['qsd']['from']) + + else: + try: + # The first path entry is the source/originator + results['source'] = results['targets'].pop(0) + + except IndexError: + # No source specified... + results['source'] = None + pass + + # 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'] += \ + NotifyPlivo.parse_phone_no(results['qsd']['to']) + + # Get Batch Mode Flag + results['batch'] = \ + parse_bool(results['qsd'].get( + 'batch', NotifyPlivo.template_args['batch']['default'])) + + return results diff --git a/packaging/redhat/python-apprise.spec b/packaging/redhat/python-apprise.spec index 7323eb0b..4f8016c4 100644 --- a/packaging/redhat/python-apprise.spec +++ b/packaging/redhat/python-apprise.spec @@ -46,13 +46,13 @@ Assistant, httpSMS, IFTTT, Join, Kavenegar, KODI, Kumulos, LaMetric, Line, LunaSea, MacOSX, Mailgun, Mastodon, Mattermost,Matrix, MessageBird, Microsoft Windows, Microsoft Teams, Misskey, MQTT, MSG91, MyAndroid, Nexmo, Nextcloud, NextcloudTalk, Notica, Notifiarr, Notifico, ntfy, Office365, OneSignal, -Opsgenie, PagerDuty, PagerTree, ParsePlatform, PopcornNotify, Prowl, Pushalot, -PushBullet, Pushjet, PushMe, Pushover, PushSafer, Pushy, PushDeer, Revolt, -Reddit, Rocket.Chat, RSyslog, SendGrid, ServerChan, SFR, Signal, SimplePush, -Sinch, Slack, SMSEagle, SMS Manager, SMTP2Go, SparkPost, Splunk, Super Toasty, -Streamlabs, Stride, Synology Chat, Syslog, Techulus Push, Telegram, Threema -Gateway, Twilio, Twitter, Twist, VictorOps, Voipms, Vonage, WeCom Bot, -WhatsApp, Webex Teams, Workflows, WxPusher, XBMC} +Opsgenie, PagerDuty, PagerTree, ParsePlatform, Plivo, PopcornNotify, Prowl, +Pushalot, PushBullet, Pushjet, PushMe, Pushover, PushSafer, Pushy, PushDeer, +Revolt, Reddit, Rocket.Chat, RSyslog, SendGrid, ServerChan, SFR, Signal, +SimplePush, Sinch, Slack, SMSEagle, SMS Manager, SMTP2Go, SparkPost, Splunk, +Super Toasty, Streamlabs, Stride, Synology Chat, Syslog, Techulus Push, +Telegram, Threema Gateway, Twilio, Twitter, Twist, VictorOps, Voipms, Vonage, +WeCom Bot, WhatsApp, Webex Teams, Workflows, WxPusher, XBMC} Name: python-%{pypi_name} Version: 1.9.0 diff --git a/setup.py b/setup.py index a062e358..b6a9eb34 100755 --- a/setup.py +++ b/setup.py @@ -1,3 +1,4 @@ +#!/usr/bin/env python # -*- coding: utf-8 -*- # BSD 2-Clause License # diff --git a/test/test_plugin_plivo.py b/test/test_plugin_plivo.py new file mode 100644 index 00000000..bb742a82 --- /dev/null +++ b/test/test_plugin_plivo.py @@ -0,0 +1,118 @@ +# -*- coding: utf-8 -*- +# BSD 3-Clause License +# +# Apprise - Push Notification Library. +# Copyright (c) 2023, 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. +# +# 3. Neither the name of the copyright holder nor the names of its +# contributors may be used to endorse or promote products derived from +# this software without specific prior written permission. +# +# 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 apprise.plugins.plivo import NotifyPlivo +from helpers import AppriseURLTester + +# Disable logging for a cleaner testing output +import logging +logging.disable(logging.CRITICAL) + +# Our Testing URLs +apprise_url_tests = ( + ('plivo://', { + # No hostname/apikey specified + 'instance': TypeError, + }), + ('plivo://{}@{}/15551232000'.format('a' * 10, 'a' * 25), { + # invalid auth id + 'instance': TypeError, + }), + ('plivo://{}@{}/15551232000'.format('a' * 25, 'a' * 10), { + # invalid token + 'instance': TypeError, + }), + ('plivo://{}@{}/123'.format('a' * 25, 'a' * 40), { + # invalid phone number + 'instance': TypeError, + }), + ('plivo://{}@{}/abc'.format('a' * 25, 'a' * 40), { + # invalid phone number + 'instance': TypeError, + }), + ('plivo://{}@{}/15551231234'.format('a' * 25, 'b' * 40), { + # target phone number becomes who we text too; all is good + 'instance': NotifyPlivo, + }), + ('plivo://{}@{}/15551232000/abcd'.format('a' * 25, 'a' * 40), { + # invalid target phone number + 'instance': NotifyPlivo, + # Notify will fail because it couldn't send to anyone + 'response': False, + }), + ('plivo://{}@{}/15551232000/123'.format('a' * 25, 'a' * 40), { + # invalid target phone number + 'instance': NotifyPlivo, + # Notify will fail because it couldn't send to anyone + 'response': False, + }), + ('plivo://{}@{}/?from=15551233000&to=15551232000&batch=yes'.format( + 'a' * 25, 'a' * 40), { + # reference to to= and from= + 'instance': NotifyPlivo, + }), + ('plivo://?id={}&token={}&from=15551233000&to=15551232000'.format( + 'a' * 25, 'a' * 40), { + # Our expected url(privacy=True) startswith() response: + 'privacy_url': 'plivo://a...a@a...a/+15551233000/+15551232000', + # reference to to= and from= + 'instance': NotifyPlivo, + }), + ('plivo://15551232123?id={}&token={}&from=15551233000' + '&to=15551232000'.format('a' * 25, 'a' * 40), { + # reference to to= and from= + 'instance': NotifyPlivo, + # Our expected url(privacy=True) startswith() response: + 'privacy_url': 'plivo://a...a@a...a/+15551233000/+15551232123'}), + ('plivo://{}@{}/15551232000'.format('a' * 25, 'a' * 40), { + 'instance': NotifyPlivo, + # throw a bizzare code forcing us to fail to look it up + 'response': False, + 'requests_response_code': 999, + }), + ('plivo://{}@{}/15551232000'.format('a' * 25, 'a' * 40), { + 'instance': NotifyPlivo, + # Throws a series of connection and transfer exceptions when this flag + # is set and tests that we gracfully handle them + 'test_requests_exceptions': True, + }), +) + + +def test_plugin_plivo_urls(): + """ + NotifyPlivo() Apprise URLs + + """ + + # Run our general tests + AppriseURLTester(tests=apprise_url_tests).run_all()