diff --git a/README.md b/README.md index 539d2a3c..c17a9c01 100644 --- a/README.md +++ b/README.md @@ -53,6 +53,7 @@ The table below identifies the services this tool supports and some example serv | [Notica](https://github.com/caronc/apprise/wiki/Notify_notica) | notica:// | (TCP) 443 | notica://Token/ | [Notifico](https://github.com/caronc/apprise/wiki/Notify_notifico) | notifico:// | (TCP) 443 | notifico://ProjectID/MessageHook/ | [Office 365](https://github.com/caronc/apprise/wiki/Notify_office365) | o365:// | (TCP) 443 | o365://TenantID:AccountEmail/ClientID/ClientSecret
o365://TenantID:AccountEmail/ClientID/ClientSecret/TargetEmail
o365://TenantID:AccountEmail/ClientID/ClientSecret/TargetEmail1/TargetEmail2/TargetEmailN +| [PopcornNotify](https://github.com/caronc/apprise/wiki/Notify_popcornnotify) | popcorn:// | (TCP) 443 | popcorn://ApiKey/ToPhoneNo
popcorn://ApiKey/ToPhoneNo1/ToPhoneNo2/ToPhoneNoN/
popcorn://ApiKey/ToEmail
popcorn://ApiKey/ToEmail1/ToEmail2/ToEmailN/
popcorn://ApiKey/ToPhoneNo1/ToEmail1/ToPhoneNoN/ToEmailN | [Prowl](https://github.com/caronc/apprise/wiki/Notify_prowl) | prowl:// | (TCP) 443 | prowl://apikey
prowl://apikey/providerkey | [PushBullet](https://github.com/caronc/apprise/wiki/Notify_pushbullet) | pbul:// | (TCP) 443 | pbul://accesstoken
pbul://accesstoken/#channel
pbul://accesstoken/A_DEVICE_ID
pbul://accesstoken/email@address.com
pbul://accesstoken/#channel/#channel2/email@address.net/DEVICE | [Pushjet](https://github.com/caronc/apprise/wiki/Notify_pushjet) | pjet:// or pjets:// | (TCP) 80 or 443 | pjet://hostname/secret
pjet://hostname:port/secret
pjets://secret@hostname/secret
pjets://hostname:port/secret diff --git a/apprise/plugins/NotifyPopcornNotify.py b/apprise/plugins/NotifyPopcornNotify.py new file mode 100644 index 00000000..c4ad6fee --- /dev/null +++ b/apprise/plugins/NotifyPopcornNotify.py @@ -0,0 +1,305 @@ +# -*- coding: utf-8 -*- +# +# Copyright (C) 2020 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. + +import re +import requests + +from .NotifyBase import NotifyBase +from ..common import NotifyType +from ..utils import GET_EMAIL_RE +from ..utils import parse_list +from ..utils import parse_bool +from ..utils import validate_regex +from ..AppriseLocale import gettext_lazy as _ + +# Some Phone Number Detection +IS_PHONE_NO = re.compile(r'^\+?(?P[0-9\s)(+-]+)\s*$') + + +class NotifyPopcornNotify(NotifyBase): + """ + A wrapper for PopcornNotify Notifications + """ + + # The default descriptive name associated with the Notification + service_name = 'PopcornNotify' + + # The services URL + service_url = 'https://popcornnotify.com/' + + # The default protocol + secure_protocol = 'popcorn' + + # A URL that takes you to the setup/help of the specific protocol + setup_url = 'https://github.com/caronc/apprise/wiki/Notify_popcornnotify' + + # PopcornNotify uses the http protocol + notify_url = 'https://popcornnotify.com/notify' + + # The maximum targets to include when doing batch transfers + default_batch_size = 10 + + # Define object templates + templates = ( + '{schema}://{apikey}/{targets}', + ) + + # Define our template tokens + template_tokens = dict(NotifyBase.template_tokens, **{ + 'apikey': { + 'name': _('API Key'), + 'type': 'string', + 'regex': (r'^[a-z0-9]+$', 'i'), + 'required': True, + }, + 'target_phone': { + 'name': _('Target Phone No'), + 'type': 'string', + 'prefix': '+', + 'regex': (r'^[0-9\s)(+-]+$', 'i'), + 'map_to': 'targets', + }, + 'target_email': { + 'name': _('Target Email'), + 'type': 'string', + 'map_to': 'targets', + }, + 'targets': { + 'name': _('Targets'), + 'type': 'list:string', + } + }) + + # 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, apikey, targets=None, batch=False, **kwargs): + """ + Initialize PopcornNotify Object + """ + super(NotifyPopcornNotify, self).__init__(**kwargs) + + # Access Token (associated with project) + self.apikey = validate_regex( + apikey, *self.template_tokens['apikey']['regex']) + if not self.apikey: + msg = 'An invalid PopcornNotify API Key ' \ + '({}) was specified.'.format(apikey) + self.logger.warning(msg) + raise TypeError(msg) + + # Prepare Batch Mode Flag + self.batch = batch + + # Parse our targets + self.targets = list() + + 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 + + result = GET_EMAIL_RE.match(target) + if result: + # store valid email + self.targets.append(target) + continue + + self.logger.warning( + 'Dropped invalid target ' + '({}) specified.'.format(target), + ) + + def send(self, body, title='', notify_type=NotifyType.INFO, **kwargs): + """ + Perform PopcornNotify Notification + """ + + if len(self.targets) == 0: + # There were no services to notify + self.logger.warning( + 'There were no PopcornNotify targets to notify.') + return False + + # error tracking (used for function return) + has_error = False + + # Prepare our headers + headers = { + 'User-Agent': self.app_id, + 'Content-Type': 'application/x-www-form-urlencoded', + } + + # Prepare our payload + payload = { + 'message': body, + 'subject': title, + } + + auth = (self.apikey, None) + + # 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 recipients + payload['recipients'] = \ + ','.join(self.targets[index:index + batch_size]) + + self.logger.debug('PopcornNotify POST URL: %s (cert_verify=%r)' % ( + self.notify_url, self.verify_certificate, + )) + self.logger.debug('PopcornNotify Payload: %s' % str(payload)) + + # Always call throttle before any remote server i/o is made + self.throttle() + try: + r = requests.post( + self.notify_url, + auth=auth, + data=payload, + headers=headers, + verify=self.verify_certificate, + timeout=self.request_timeout, + ) + if r.status_code != requests.codes.ok: + # We had a problem + status_str = \ + NotifyPopcornNotify.http_response_code_lookup( + r.status_code) + + self.logger.warning( + 'Failed to send {} PopcornNotify 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( + 'Sent {} PopcornNotify 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 {} PopcornNotify ' + 'notification(s).'.format( + len(self.targets[index:index + batch_size]))) + self.logger.debug('Socket Exception: %s' % str(e)) + + # Mark our failure + has_error = True + continue + + return not has_error + + def url(self, privacy=False, *args, **kwargs): + """ + 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', + } + + return '{schema}://{apikey}/{targets}/?{args}'.format( + schema=self.secure_protocol, + apikey=self.pprint(self.apikey, privacy, safe=''), + targets='/'.join( + [NotifyPopcornNotify.quote(x, safe='') for x in self.targets]), + args=NotifyPopcornNotify.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) + + if not results: + # We're done early as we couldn't load the results + return results + + # Get our entries; split_path() looks after unquoting content for us + # by default + results['targets'] = \ + NotifyPopcornNotify.split_path(results['fullpath']) + + # The hostname is our authentication key + results['apikey'] = NotifyPopcornNotify.unquote(results['host']) + + # 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'] += \ + NotifyPopcornNotify.parse_list(results['qsd']['to']) + + # Get Batch Mode Flag + results['batch'] = \ + parse_bool(results['qsd'].get('batch', False)) + + return results diff --git a/packaging/redhat/python-apprise.spec b/packaging/redhat/python-apprise.spec index 58f55d91..77d043e6 100644 --- a/packaging/redhat/python-apprise.spec +++ b/packaging/redhat/python-apprise.spec @@ -50,10 +50,10 @@ it easy to access: Boxcar, ClickSend, Discord, E-Mail, Emby, Faast, Flock, Gitter, Gotify, Growl, IFTTT, Join, Kavenegar, KODI, Kumulos, MacOSX, Mailgun, MatterMost, Matrix, Microsoft Windows, Microsoft Teams, MessageBird, MSG91, MyAndroid, Nexmo, -Nextcloud, Notica, Notifico, Office365, Prowl, Pushalot, PushBullet, -Pushjet, Pushover, PushSafer, Rocket.Chat, SendGrid, SimplePush, Sinch, Slack, -Super Toasty, Stride, Syslog, Techulus Push, Telegram, Twilio, Twitter, Twist, -XBMC, XMPP, Webex Teams} +Nextcloud, Notica, Notifico, Office365, PopcornNotify, Prowl, Pushalot, +PushBullet, Pushjet, Pushover, PushSafer, Rocket.Chat, SendGrid, SimplePush, +Sinch, Slack, Super Toasty, Stride, Syslog, Techulus Push, Telegram, Twilio, +Twitter, Twist, XBMC, XMPP, Webex Teams} Name: python-%{pypi_name} Version: 0.8.6 diff --git a/setup.py b/setup.py index 8449a05e..47c0a6fb 100755 --- a/setup.py +++ b/setup.py @@ -72,10 +72,11 @@ setup( keywords='Push Notifications Alerts Email AWS SNS Boxcar ClickSend ' 'Discord Dbus Emby Faast Flock Gitter Gnome Gotify Growl IFTTT Join ' 'Kavenegar KODI Kumulos MacOS Mailgun Matrix Mattermost MessageBird ' - 'MSG91 Nexmo Nextcloud Notica Notifico Office365 Prowl PushBullet ' - 'Pushjet Pushed Pushover PushSafer Rocket.Chat Ryver SendGrid ' - 'SimplePush Sinch Slack Stride Syslog Techulus Push Telegram Twilio ' - 'Twist Twitter XBMC Microsoft MSTeams Windows Webex CLI API', + 'MSG91 Nexmo Nextcloud Notica Notifico Office365 PopcornNotify Prowl ' + 'PushBullet Pushjet Pushed Pushover PushSafer Rocket.Chat Ryver ' + 'SendGrid SimplePush Sinch Slack Stride Syslog 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 49e7859d..f528b35a 100644 --- a/test/test_rest_plugins.py +++ b/test/test_rest_plugins.py @@ -4001,6 +4001,58 @@ TEST_URLS = ( 'test_requests_exceptions': True, }), + ################################## + # NotifyPopcorn (PopcornNotify) + ################################## + ('popcorn://', { + # No hostname/apikey specified + 'instance': None, + }), + ('popcorn://{}/18001231234'.format('_' * 9), { + # invalid apikey + 'instance': TypeError, + }), + ('popcorn://{}/1232348923489234923489234289-32423'.format('a' * 9), { + # invalid phone number + 'instance': plugins.NotifyPopcornNotify, + 'notify_response': False, + }), + ('popcorn://{}/abc'.format('b' * 9), { + # invalid email + 'instance': plugins.NotifyPopcornNotify, + 'notify_response': False, + }), + ('popcorn://{}/15551232000/user@example.com'.format('c' * 9), { + # value phone and email + 'instance': plugins.NotifyPopcornNotify, + }), + ('popcorn://{}/15551232000/user@example.com?batch=yes'.format('w' * 9), { + # value phone and email with batch mode set + 'instance': plugins.NotifyPopcornNotify, + }), + ('popcorn://{}/?to=15551232000'.format('w' * 9), { + # reference to to= + 'instance': plugins.NotifyPopcornNotify, + }), + ('popcorn://{}/15551232000'.format('x' * 9), { + 'instance': plugins.NotifyPopcornNotify, + # force a failure + 'response': False, + 'requests_response_code': requests.codes.internal_server_error, + }), + ('popcorn://{}/15551232000'.format('y' * 9), { + 'instance': plugins.NotifyPopcornNotify, + # throw a bizzare code forcing us to fail to look it up + 'response': False, + 'requests_response_code': 999, + }), + ('popcorn://{}/15551232000'.format('z' * 9), { + 'instance': plugins.NotifyPopcornNotify, + # Throws a series of connection and transfer exceptions when this flag + # is set and tests that we gracfully handle them + 'test_requests_exceptions': True, + }), + ################################## # NotifyWebexTeams ##################################