From a023361db48325a9f80d72c4db1502a356165bf8 Mon Sep 17 00:00:00 2001 From: Chris Caron Date: Tue, 29 Dec 2020 10:49:33 -0500 Subject: [PATCH] Added OneSignal Support (#335) --- README.md | 1 + apprise/plugins/NotifyBase.py | 5 +- apprise/plugins/NotifyOneSignal.py | 487 +++++++++++++++++++++++++++ packaging/redhat/python-apprise.spec | 9 +- setup.py | 8 +- test/test_rest_plugins.py | 77 +++++ 6 files changed, 576 insertions(+), 11 deletions(-) create mode 100644 apprise/plugins/NotifyOneSignal.py diff --git a/README.md b/README.md index 6a7366ef..d6d1e95e 100644 --- a/README.md +++ b/README.md @@ -57,6 +57,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 +| [OneSignal](https://github.com/caronc/apprise/wiki/Notify_onesignal) | onesignal:// | (TCP) 443 | onesignal://AppID@APIKey/PlayerID
onesignal://TemplateID:AppID@APIKey/UserID
onesignal://AppID@APIKey/IncludeSegment
onesignal://AppID@APIKey/Email | [ParsePlatform](https://github.com/caronc/apprise/wiki/Notify_parseplatform) | parsep:// or parseps:// | (TCP) 80 or 443 | parsep://AppID:MasterKey@Hostname
parseps://AppID:MasterKey@Hostname | [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 diff --git a/apprise/plugins/NotifyBase.py b/apprise/plugins/NotifyBase.py index 3a0538bc..ed64cf0d 100644 --- a/apprise/plugins/NotifyBase.py +++ b/apprise/plugins/NotifyBase.py @@ -153,7 +153,8 @@ class NotifyBase(BASE_OBJECT): # Provide override self.overflow_mode = overflow - def image_url(self, notify_type, logo=False, extension=None): + def image_url(self, notify_type, logo=False, extension=None, + image_size=None): """ Returns Image URL if possible """ @@ -166,7 +167,7 @@ class NotifyBase(BASE_OBJECT): return self.asset.image_url( notify_type=notify_type, - image_size=self.image_size, + image_size=self.image_size if image_size is None else image_size, logo=logo, extension=extension, ) diff --git a/apprise/plugins/NotifyOneSignal.py b/apprise/plugins/NotifyOneSignal.py new file mode 100644 index 00000000..03d9d40a --- /dev/null +++ b/apprise/plugins/NotifyOneSignal.py @@ -0,0 +1,487 @@ +# -*- 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. + +# One Signal requires that you've signed up with the service and +# generated yourself an API Key and APP ID. + +# Sources: +# - https://documentation.onesignal.com/docs/accounts-and-keys +# - https://documentation.onesignal.com/reference/create-notification + +import requests +from json import dumps +from itertools import chain + +from .NotifyBase import NotifyBase +from ..common import NotifyType +from ..common import NotifyImageSize +from ..utils import validate_regex +from ..utils import parse_list +from ..utils import parse_bool +from ..utils import is_email +from ..AppriseLocale import gettext_lazy as _ + + +class OneSignalCategory(NotifyBase): + """ + We define the different category types that we can notify via OneSignal + """ + PLAYER = 'include_player_ids' + EMAIL = 'include_email_tokens' + USER = 'include_external_user_ids' + SEGMENT = 'included_segments' + + +ONESIGNAL_CATEGORIES = ( + OneSignalCategory.PLAYER, + OneSignalCategory.EMAIL, + OneSignalCategory.USER, + OneSignalCategory.SEGMENT, +) + + +class NotifyOneSignal(NotifyBase): + """ + A wrapper for OneSignal Notifications + """ + # The default descriptive name associated with the Notification + service_name = 'OneSignal' + + # The services URL + service_url = 'https://onesignal.com' + + # The default protocol + secure_protocol = 'onesignal' + + # A URL that takes you to the setup/help of the specific protocol + setup_url = 'https://github.com/caronc/apprise/wiki/Notify_onesignal' + + # Notification + notify_url = "https://onesignal.com/api/v1/notifications" + + # Allows the user to specify the NotifyImageSize object + image_size = NotifyImageSize.XY_72 + + # The maximum allowable batch sizes per message + maximum_batch_size = 2000 + + # Define object templates + templates = ( + '{schema}://{app}@{apikey}/{targets}', + '{schema}://{template}:{app}@{apikey}/{targets}', + ) + + # Define our template + template_tokens = dict(NotifyBase.template_tokens, **{ + # The App_ID is a UUID + # such as: 8250eaf6-1a58-489e-b136-7c74a864b434 + 'app': { + 'name': _('App ID'), + 'type': 'string', + 'private': True, + 'required': True, + }, + 'template': { + 'name': _('Template'), + 'type': 'string', + 'private': True, + }, + 'apikey': { + 'name': _('API Key'), + 'type': 'string', + 'private': True, + 'required': True, + }, + 'target_device': { + 'name': _('Target Player ID'), + 'type': 'string', + 'map_to': 'targets', + }, + 'target_email': { + 'name': _('Target Email'), + 'type': 'string', + 'map_to': 'targets', + }, + 'target_user': { + 'name': _('Target User'), + 'type': 'string', + 'prefix': '@', + 'map_to': 'targets', + }, + 'target_segment': { + 'name': _('Include Segment'), + 'type': 'string', + 'prefix': '#', + 'map_to': 'targets', + }, + 'targets': { + 'name': _('Targets'), + 'type': 'list:string', + }, + }) + + template_args = dict(NotifyBase.template_args, **{ + 'to': { + 'alias_of': 'targets', + }, + 'image': { + 'name': _('Include Image'), + 'type': 'bool', + 'default': True, + 'map_to': 'include_image', + }, + 'batch': { + 'name': _('Batch Mode'), + 'type': 'bool', + 'default': False, + }, + 'template': { + 'alias_of': 'template', + }, + 'subtitle': { + 'name': _('Subtitle'), + 'type': 'string', + }, + 'language': { + 'name': _('Language'), + 'type': 'string', + 'default': 'en', + }, + }) + + def __init__(self, app, apikey, targets=None, include_image=True, + template=None, subtitle=None, language=None, batch=False, + **kwargs): + """ + Initialize OneSignal + + """ + super(NotifyOneSignal, self).__init__(**kwargs) + + # The apikey associated with the account + self.apikey = validate_regex(apikey) + if not self.apikey: + msg = 'An invalid OneSignal API key ' \ + '({}) was specified.'.format(apikey) + self.logger.warning(msg) + raise TypeError(msg) + + # The App ID associated with the account + self.app = validate_regex(app) + if not self.app: + msg = 'An invalid OneSignal Application ID ' \ + '({}) was specified.'.format(app) + self.logger.warning(msg) + raise TypeError(msg) + + # Prepare Batch Mode Flag + self.batch_size = self.maximum_batch_size if batch else 1 + + # Place a thumbnail image inline with the message body + self.include_image = include_image + + # Our Assorted Types of Targets + self.targets = { + OneSignalCategory.PLAYER: [], + OneSignalCategory.EMAIL: [], + OneSignalCategory.USER: [], + OneSignalCategory.SEGMENT: [], + } + + # Assign our template (if defined) + self.template_id = template + + # Assign our subtitle (if defined) + self.subtitle = subtitle + + # Our Language + self.language = language.strip().lower()[0:2]\ + if language \ + else NotifyOneSignal.template_args['language']['default'] + + if not self.language or len(self.language) != 2: + msg = 'An invalid OneSignal Language ({}) was specified.'.format( + language) + self.logger.warning(msg) + raise TypeError(msg) + + # Sort our targets + for _target in parse_list(targets): + target = _target.strip() + if len(target) < 2: + self.logger.debug('Ignoring OneSignal Entry: %s' % target) + continue + + if target.startswith( + NotifyOneSignal.template_tokens + ['target_user']['prefix']): + + self.targets[OneSignalCategory.USER].append(target) + self.logger.debug( + 'Detected OneSignal UserID: %s' % + self.targets[OneSignalCategory.USER][-1]) + continue + + if target.startswith( + NotifyOneSignal.template_tokens + ['target_segment']['prefix']): + + self.targets[OneSignalCategory.SEGMENT].append(target) + self.logger.debug( + 'Detected OneSignal Include Segment: %s' % + self.targets[OneSignalCategory.SEGMENT][-1]) + continue + + result = is_email(target) + if result: + self.targets[OneSignalCategory.EMAIL]\ + .append(result['full_email']) + self.logger.debug( + 'Detected OneSignal Email: %s' % + self.targets[OneSignalCategory.EMAIL][-1]) + + else: + # Add element as Player ID + self.targets[OneSignalCategory.PLAYER].append(target) + self.logger.debug( + 'Detected OneSignal Player ID: %s' % + self.targets[OneSignalCategory.PLAYER][-1]) + + return + + def send(self, body, title='', notify_type=NotifyType.INFO, **kwargs): + """ + Perform OneSignal Notification + """ + + headers = { + 'User-Agent': self.app_id, + 'Content-Type': 'application/json; charset=utf-8', + "Authorization": "Basic {}".format(self.apikey), + } + + has_error = False + sent_count = 0 + + payload = { + 'app_id': self.app, + + 'headings': { + self.language: title if title else self.app_desc, + }, + 'contents': { + self.language: body, + }, + + # Sending true wakes your app from background to run custom native + # code (Apple interprets this as content-available=1). + # Note: Not applicable if the app is in the "force-quit" state + # (i.e app was swiped away). Omit the contents field to + # prevent displaying a visible notification. + 'content_available': True, + } + + if self.subtitle: + payload.update({ + 'subtitle': { + self.language: self.subtitle, + }, + }) + + if self.template_id: + payload['template_id'] = self.template_id + + # Acquire our large_icon image URL (if set) + image_url = None if not self.include_image \ + else self.image_url(notify_type) + if image_url: + payload['large_icon'] = image_url + + # Acquire our small_icon image URL (if set) + image_url = None if not self.include_image \ + else self.image_url(notify_type, image_size=NotifyImageSize.XY_32) + if image_url: + payload['small_icon'] = image_url + + for category in ONESIGNAL_CATEGORIES: + # Create a pointer to our list of targets for specified category + targets = self.targets[category] + for index in range(0, len(targets), self.batch_size): + payload[category] = targets[index:index + self.batch_size] + + # Track our sent count + sent_count += len(payload[category]) + + self.logger.debug('OneSignal POST URL: %s (cert_verify=%r)' % ( + self.notify_url, self.verify_certificate, + )) + self.logger.debug('OneSignal 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, + timeout=self.request_timeout, + ) + if r.status_code not in ( + requests.codes.ok, requests.codes.no_content): + # We had a problem + status_str = \ + NotifyOneSignal.http_response_code_lookup( + r.status_code) + + self.logger.warning( + 'Failed to send OneSignal notification: ' + '{}{}error={}.'.format( + status_str, + ', ' if status_str else '', + r.status_code)) + + self.logger.debug( + 'Response Details:\r\n%s', r.content) + + has_error = True + + else: + self.logger.info('Sent OneSignal notification.') + + except requests.RequestException as e: + self.logger.warning( + 'A Connection error occurred sending OneSignal ' + 'notification.' + ) + self.logger.debug('Socket Exception: %s', str(e)) + + has_error = True + + if not sent_count: + # There is no one to notify; we need to capture this and not + # return a valid + self.logger.warning('There are no OneSignal targets to notify') + return False + + return not has_error + + def url(self, privacy=False, *args, **kwargs): + """ + Returns the URL built dynamically based on specified arguments. + """ + + # Define any URL parameters + params = { + 'image': 'yes' if self.include_image else 'no', + } + + # Extend our parameters + params.update(self.url_parameters(privacy=privacy, *args, **kwargs)) + + return '{schema}://{tp_id}{app}@{apikey}/{targets}?{params}'.format( + schema=self.secure_protocol, + tp_id='{}:'.format( + self.pprint(self.template_id, privacy, safe='')) + if self.template_id else '', + app=self.pprint(self.app, privacy, safe=''), + apikey=self.pprint(self.apikey, privacy, safe=''), + targets='/'.join(chain( + [NotifyOneSignal.quote(x) + for x in self.targets[OneSignalCategory.PLAYER]], + [NotifyOneSignal.quote(x) + for x in self.targets[OneSignalCategory.EMAIL]], + [NotifyOneSignal.quote('{}{}'.format( + NotifyOneSignal.template_tokens + ['target_user']['prefix'], x), safe='') + for x in self.targets[OneSignalCategory.USER]], + [NotifyOneSignal.quote('{}{}'.format( + NotifyOneSignal.template_tokens + ['target_segment']['prefix'], x), safe='') + for x in self.targets[OneSignalCategory.SEGMENT]])), + params=NotifyOneSignal.urlencode(params), + ) + + @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 load the results + return results + + if not results.get('password'): + # The APP ID identifier associated with the account + results['app'] = NotifyOneSignal.unquote(results['user']) + + else: + # The APP ID identifier associated with the account + results['app'] = NotifyOneSignal.unquote(results['password']) + # The Template ID + results['template'] = NotifyOneSignal.unquote(results['user']) + + # Get Image Boolean (if set) + results['include_image'] = \ + parse_bool( + results['qsd'].get( + 'image', + NotifyOneSignal.template_args['image']['default'])) + + # The API Key is stored in the hostname + results['apikey'] = NotifyOneSignal.unquote(results['host']) + + # Get our Device IDs + results['targets'] = NotifyOneSignal.split_path(results['fullpath']) + + # The 'to' makes it easier to use yaml configuration + if 'to' in results['qsd'] and len(results['qsd']['to']): + results['targets'] += \ + NotifyOneSignal.parse_list(results['qsd']['to']) + + if 'app' in results['qsd'] and len(results['qsd']['app']): + results['app'] = \ + NotifyOneSignal.unquote(results['qsd']['app']) + + if 'apikey' in results['qsd'] and len(results['qsd']['apikey']): + results['apikey'] = \ + NotifyOneSignal.unquote(results['qsd']['apikey']) + + if 'template' in results['qsd'] and len(results['qsd']['template']): + results['template'] = \ + NotifyOneSignal.unquote(results['qsd']['template']) + + if 'subtitle' in results['qsd'] and len(results['qsd']['subtitle']): + results['subtitle'] = \ + NotifyOneSignal.unquote(results['qsd']['subtitle']) + + if 'lang' in results['qsd'] and len(results['qsd']['lang']): + results['language'] = \ + NotifyOneSignal.unquote(results['qsd']['lang']) + + return results diff --git a/packaging/redhat/python-apprise.spec b/packaging/redhat/python-apprise.spec index d5762fb5..1e2f5ed8 100644 --- a/packaging/redhat/python-apprise.spec +++ b/packaging/redhat/python-apprise.spec @@ -50,11 +50,10 @@ it easy to access: Boxcar, ClickSend, Discord, E-Mail, Emby, Faast, FCM, Flock, Gitter, Gotify, Growl, IFTTT, Join, Kavenegar, KODI, Kumulos, LaMetric, MacOSX, Mailgun, MatterMost, Matrix, Microsoft Windows, Microsoft Teams, MessageBird, MSG91, -MyAndroid, Nexmo, Nextcloud, Notica, Notifico, Office365, ParsePlatform, -PopcornNotify, Prowl, Pushalot, PushBullet, Pushjet, Pushover, PushSafer, -Rocket.Chat, SendGrid, SimplePush, Sinch, Slack, Spontit, SparkPost, Super -Toasty, Stride, Syslog, Techulus Push, Telegram, Twilio, Twitter, Twist, XBMC, -XMPP, Webex Teams} +MyAndroid, Nexmo, Nextcloud, Notica, Notifico, Office365, OneSignal, +ParsePlatform, PopcornNotify, Prowl, Pushalot, PushBullet, Pushjet, Pushover, +PushSafer, Rocket.Chat, SendGrid, SimplePush, Sinch, Slack, Spontit, +SparkPost, Super Toasty, Stride, Syslog, Techulus Push, Telegram, Twilio, Twitter, Twist, XBMC, XMPP, Webex Teams} Name: python-%{pypi_name} Version: 0.8.9 diff --git a/setup.py b/setup.py index 2e9b2a43..a8a1050c 100755 --- a/setup.py +++ b/setup.py @@ -73,10 +73,10 @@ setup( 'Discord Dbus Emby Faast FCM Flock Gitter Gnome Gotify Growl IFTTT ' 'Join Kavenegar KODI Kumulos LaMetric MacOS Mailgun Matrix Mattermost ' 'MessageBird MSG91 Nexmo Nextcloud Notica Notifico Office365 ' - 'ParsePlatform PopcornNotify Prowl PushBullet Pushjet Pushed Pushover ' - 'PushSafer Rocket.Chat Ryver SendGrid SimplePush Sinch Slack ' - 'SparkPost Spontit Stride Syslog Techulus Push Telegram Twilio Twist ' - 'Twitter XBMC MSTeams Microsoft Windows Webex CLI API', + 'OneSignal ParsePlatform PopcornNotify Prowl PushBullet Pushjet ' + 'Pushed Pushover PushSafer Rocket.Chat Ryver SendGrid SimplePush ' + 'Sinch Slack SparkPost Spontit Stride Syslog Techulus Telegram ' + 'Twilio Twist Twitter XBMC MSTeams Microsoft 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 2834d1eb..534551a7 100644 --- a/test/test_rest_plugins.py +++ b/test/test_rest_plugins.py @@ -2319,6 +2319,83 @@ TEST_URLS = ( 'test_requests_exceptions': True, }), + ################################## + # NotifyOneSignal + ################################## + ('onesignal://', { + # We failed to identify any valid authentication + 'instance': TypeError, + }), + ('onesignal://:@/', { + # We failed to identify any valid authentication + 'instance': TypeError, + }), + ('onesignal://apikey/', { + # no app id specified + 'instance': TypeError, + }), + ('onesignal://appid@%20%20/', { + # invalid apikey + 'instance': TypeError, + }), + ('onesignal://appid@apikey/playerid/?lang=X', { + # invalid language id (must be 2 characters) + 'instance': TypeError, + }), + ('onesignal://appid@apikey/', { + # No targets specified; we will initialize but not notify anything + 'instance': plugins.NotifyOneSignal, + 'notify_response': False, + }), + ('onesignal://appid@apikey/playerid', { + # Valid playerid + 'instance': plugins.NotifyOneSignal, + 'privacy_url': 'onesignal://a...d@a...y/playerid', + }), + ('onesignal://appid@apikey/player', { + # Valid player id + 'instance': plugins.NotifyOneSignal, + # don't include an image by default + 'include_image': False, + }), + ('onesignal://appid@apikey/@user?image=no', { + # Valid userid, no image + 'instance': plugins.NotifyOneSignal, + }), + ('onesignal://appid@apikey/user@email.com/#seg/player/@user/%20/a', { + # Valid email, valid playerid, valid user, invalid entry (%20), + # and too short of an entry (a) + 'instance': plugins.NotifyOneSignal, + }), + ('onesignal://appid@apikey?to=#segment,playerid', { + # Test to= + 'instance': plugins.NotifyOneSignal, + }), + ('onesignal://templateid:appid@apikey/playerid', { + # Test Template ID + 'instance': plugins.NotifyOneSignal, + }), + ('onesignal://appid@apikey/playerid/?lang=es&subtitle=Sub', { + # Test Language and Subtitle Over-ride + 'instance': plugins.NotifyOneSignal, + }), + ('onesignal://?apikey=abc&template=tp&app=123&to=playerid', { + # Test Kwargs + 'instance': plugins.NotifyOneSignal, + }), + ('onesignal://appid@apikey/#topic1/device/', { + 'instance': plugins.NotifyOneSignal, + # throw a bizzare code forcing us to fail to look it up + 'response': False, + 'requests_response_code': 999, + }), + ('onesignal://appid@apikey/#topic1/device/', { + 'instance': plugins.NotifyOneSignal, + # Throws a series of connection and transfer exceptions when this flag + # is set and tests that we gracfully handle them + 'test_requests_exceptions': True, + }), + ################################## # NotifyParsePlatform ##################################