From d22ce8d5b7c713a725f5788efeccd49c984249fb Mon Sep 17 00:00:00 2001 From: Chris Caron Date: Tue, 16 Jul 2024 16:16:26 -0400 Subject: [PATCH] OneSignal to support custom data in payload (#1163) Co-authored-by: phantom943 <105282577+phantom943@users.noreply.github.com> --- apprise/plugins/one_signal.py | 113 ++++++++++++++++++++++++++++---- test/test_plugin_onesignal.py | 117 ++++++++++++++++++++++++++++++++++ 2 files changed, 218 insertions(+), 12 deletions(-) diff --git a/apprise/plugins/one_signal.py b/apprise/plugins/one_signal.py index 76ec212f..9a5207e2 100644 --- a/apprise/plugins/one_signal.py +++ b/apprise/plugins/one_signal.py @@ -82,7 +82,7 @@ class NotifyOneSignal(NotifyBase): setup_url = 'https://github.com/caronc/apprise/wiki/Notify_onesignal' # Notification - notify_url = "https://onesignal.com/api/v1/notifications" + notify_url = "https://api.onesignal.com/notifications" # Allows the user to specify the NotifyImageSize object image_size = NotifyImageSize.XY_72 @@ -161,6 +161,12 @@ class NotifyOneSignal(NotifyBase): 'type': 'bool', 'default': False, }, + 'contents': { + 'name': _('Enable Contents'), + 'type': 'bool', + 'default': True, + 'map_to': 'use_contents', + }, 'template': { 'alias_of': 'template', }, @@ -175,9 +181,21 @@ class NotifyOneSignal(NotifyBase): }, }) + # Define our token control + template_kwargs = { + 'custom': { + 'name': _('Custom Data'), + 'prefix': ':', + }, + 'postback': { + 'name': _('Postback Data'), + 'prefix': '+', + }, + } + def __init__(self, app, apikey, targets=None, include_image=True, - template=None, subtitle=None, language=None, batch=False, - **kwargs): + template=None, subtitle=None, language=None, batch=None, + use_contents=None, custom=None, postback=None, **kwargs): """ Initialize OneSignal @@ -201,7 +219,14 @@ class NotifyOneSignal(NotifyBase): raise TypeError(msg) # Prepare Batch Mode Flag - self.batch_size = self.default_batch_size if batch else 1 + self.batch_size = self.default_batch_size if ( + batch if batch is not None else + self.template_args['batch']['default']) else 1 + + # Prepare Use Contents Flag + self.use_contents = True if ( + use_contents if use_contents is not None else + self.template_args['contents']['default']) else False # Place a thumbnail image inline with the message body self.include_image = include_image @@ -273,6 +298,27 @@ class NotifyOneSignal(NotifyBase): 'Detected OneSignal Player ID: %s' % self.targets[OneSignalCategory.PLAYER][-1]) + # Custom Data + self.custom_data = {} + if custom and isinstance(custom, dict): + self.custom_data.update(custom) + + elif custom: + msg = 'The specified OneSignal Custom Data ' \ + '({}) are not identified as a dictionary.'.format(custom) + self.logger.warning(msg) + raise TypeError(msg) + + # Postback Data + self.postback_data = {} + if postback and isinstance(postback, dict): + self.postback_data.update(postback) + + elif postback: + msg = 'The specified OneSignal Postback Data ' \ + '({}) are not identified as a dictionary.'.format(postback) + self.logger.warning(msg) + raise TypeError(msg) return def send(self, body, title='', notify_type=NotifyType.INFO, **kwargs): @@ -291,14 +337,9 @@ class NotifyOneSignal(NotifyBase): 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 @@ -307,6 +348,33 @@ class NotifyOneSignal(NotifyBase): 'content_available': True, } + if self.template_id: + # Store template information + payload['template_id'] = self.template_id + + if not self.use_contents: + # Only if a template is defined can contents be removed + del payload['contents'] + + # Set our data if defined + if self.custom_data: + payload.update({ + 'custom_data': self.custom_data, + }) + + # Set our postback data if defined + if self.postback_data: + payload.update({ + 'data': self.postback_data, + }) + + if title: + # Display our title if defined + payload.update({ + 'headings': { + self.language: title, + }}) + if self.subtitle: payload.update({ 'subtitle': { @@ -314,9 +382,6 @@ class NotifyOneSignal(NotifyBase): }, }) - 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) @@ -406,6 +471,17 @@ class NotifyOneSignal(NotifyBase): # Extend our parameters params.update(self.url_parameters(privacy=privacy, *args, **kwargs)) + # Save our template data + params.update( + {':{}'.format(k): v for k, v in self.custom_data.items()}) + + # Save our postback data + params.update( + {'+{}'.format(k): v for k, v in self.postback_data.items()}) + + if self.use_contents != self.template_args['contents']['default']: + params['contents'] = 'yes' if self.use_contents else 'no' + return '{schema}://{tp_id}{app}@{apikey}/{targets}?{params}'.format( schema=self.secure_protocol, tp_id='{}:'.format( @@ -485,6 +561,13 @@ class NotifyOneSignal(NotifyBase): 'batch', NotifyOneSignal.template_args['batch']['default'])) + # Get Use Contents Boolean (if set) + results['use_contents'] = \ + parse_bool( + results['qsd'].get( + 'contents', + NotifyOneSignal.template_args['contents']['default'])) + # The API Key is stored in the hostname results['apikey'] = NotifyOneSignal.unquote(results['host']) @@ -516,4 +599,10 @@ class NotifyOneSignal(NotifyBase): results['language'] = \ NotifyOneSignal.unquote(results['qsd']['lang']) + # Store our custom data + results['custom'] = results['qsd:'] + + # Store our postback data + results['postback'] = results['qsd+'] + return results diff --git a/test/test_plugin_onesignal.py b/test/test_plugin_onesignal.py index e6b32e5b..e9bfecea 100644 --- a/test/test_plugin_onesignal.py +++ b/test/test_plugin_onesignal.py @@ -26,6 +26,10 @@ # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. +from json import loads +from unittest import mock +import pytest +import requests from apprise.plugins.one_signal import NotifyOneSignal from helpers import AppriseURLTester from apprise import Apprise @@ -105,6 +109,16 @@ apprise_url_tests = ( # Test Kwargs 'instance': NotifyOneSignal, }), + ('onesignal://?apikey=abc&template=tp&app=123&to=playerid&body=no' + '&:key1=val1&:key2=val2', { + # Test Kwargs + 'instance': NotifyOneSignal, + }), + ('onesignal://?apikey=abc&template=tp&app=123&to=playerid&body=no' + '&+key1=val1&+key2=val2', { + # Test Kwargs + 'instance': NotifyOneSignal, + }), ('onesignal://appid@apikey/#segment/playerid/', { 'instance': NotifyOneSignal, # throw a bizzare code forcing us to fail to look it up @@ -244,3 +258,106 @@ def test_plugin_onesignal_edge_cases(): # Individual queries assert len(obj) == 16 + + # custom must be a dictionary + with pytest.raises(TypeError): + NotifyOneSignal( + app='appid', apikey='key', targets=['@user'], custom='not-a-dict') + + # postback must be a dictionary + with pytest.raises(TypeError): + NotifyOneSignal( + app='appid', apikey='key', targets=['@user'], + custom=[], postback='not-a-dict') + + +@mock.patch('requests.post') +def test_plugin_onesignal_notifications(mock_post): + """ + OneSignal() Notifications Support + + """ + # Prepare Mock + mock_post.return_value = requests.Request() + mock_post.return_value.status_code = requests.codes.ok + + # Load URL with Template + instance = Apprise.instantiate( + 'onesignal://templateid:appid@apikey/@user/?:key1=value1&+key3=value3') + + # Validate that it loaded okay + assert isinstance(instance, NotifyOneSignal) + + response = instance.notify("hello world") + assert response is True + assert mock_post.call_count == 1 + assert mock_post.call_args_list[0][0][0] == \ + 'https://api.onesignal.com/notifications' + + details = mock_post.call_args_list[0] + payload = loads(details[1]['data']) + + assert payload == { + 'app_id': 'appid', + 'contents': {'en': 'hello world'}, + 'content_available': True, + 'template_id': 'templateid', + 'custom_data': {'key1': 'value1'}, + 'data': {'key3': 'value3'}, + 'large_icon': 'https://github.com/caronc/apprise' + '/raw/master/apprise/assets/themes/default/apprise-info-72x72.png', + 'small_icon': 'https://github.com/caronc/apprise' + '/raw/master/apprise/assets/themes/default/apprise-info-32x32.png', + 'include_external_user_ids': ['@user']} + + mock_post.reset_mock() + + # Load URL with Template and disable body + instance = Apprise.instantiate( + 'onesignal://templateid:appid@apikey/@user/?contents=no') + + # Validate that it loaded okay + assert isinstance(instance, NotifyOneSignal) + + response = instance.notify("hello world") + assert response is True + assert mock_post.call_count == 1 + assert mock_post.call_args_list[0][0][0] == \ + 'https://api.onesignal.com/notifications' + + details = mock_post.call_args_list[0] + payload = loads(details[1]['data']) + + assert payload == { + 'app_id': 'appid', + 'content_available': True, + 'template_id': 'templateid', + 'large_icon': 'https://github.com/caronc/apprise' + '/raw/master/apprise/assets/themes/default/apprise-info-72x72.png', + 'small_icon': 'https://github.com/caronc/apprise' + '/raw/master/apprise/assets/themes/default/apprise-info-32x32.png', + 'include_external_user_ids': ['@user']} + + # Now set a title + mock_post.reset_mock() + + response = instance.notify("hello world", title="mytitle") + + assert response is True + assert mock_post.call_count == 1 + assert mock_post.call_args_list[0][0][0] == \ + 'https://api.onesignal.com/notifications' + + details = mock_post.call_args_list[0] + payload = loads(details[1]['data']) + + assert payload == { + 'app_id': 'appid', + 'headings': {'en': 'mytitle'}, + 'content_available': True, + 'template_id': 'templateid', + 'large_icon': 'https://github.com/caronc/apprise' + '/raw/master/apprise/assets/themes/default/apprise-info-72x72.png', + 'small_icon': 'https://github.com/caronc/apprise' + '/raw/master/apprise/assets/themes/default/apprise-info-32x32.png', + 'include_external_user_ids': ['@user']}