diff --git a/README.md b/README.md index 344374cb..ffceb291 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,7 @@ The table below identifies the services this tool supports and some example serv | Notification Service | Service ID | Default Port | Example Syntax | | -------------------- | ---------- | ------------ | -------------- | +| [Apprise API](https://github.com/caronc/apprise/wiki/Notify_apprise_api) | apprise:// or apprises:// | (TCP) 80 or 443 | apprise://hostname/Token | [Boxcar](https://github.com/caronc/apprise/wiki/Notify_boxcar) | boxcar:// | (TCP) 443 | boxcar://hostname
boxcar://hostname/@tag
boxcar://hostname/device_token
boxcar://hostname/device_token1/device_token2/device_tokenN
boxcar://hostname/@tag/@tag2/device_token | [Discord](https://github.com/caronc/apprise/wiki/Notify_discord) | discord:// | (TCP) 443 | discord://webhook_id/webhook_token
discord://avatar@webhook_id/webhook_token | [Emby](https://github.com/caronc/apprise/wiki/Notify_emby) | emby:// or embys:// | (TCP) 8096 | emby://user@hostname/
emby://user:password@hostname diff --git a/apprise/AppriseAsset.py b/apprise/AppriseAsset.py index af491008..e2e95b4a 100644 --- a/apprise/AppriseAsset.py +++ b/apprise/AppriseAsset.py @@ -24,7 +24,7 @@ # THE SOFTWARE. import re - +from uuid import uuid4 from os.path import join from os.path import dirname from os.path import isfile @@ -121,6 +121,20 @@ class AppriseAsset(object): # that you leave this option as is otherwise. secure_logging = True + # All internal/system flags are prefixed with an underscore (_) + # These can only be initialized using Python libraries and are not picked + # up from (yaml) configuration files (if set) + + # An internal counter that is used by AppriseAPI + # (https://github.com/caronc/apprise-api). The idea is to allow one + # instance of AppriseAPI to call another, but to track how many times + # this occurs. It's intent is to prevent a loop where an AppriseAPI + # Server calls itself (or loops indefinitely) + _recursion = 0 + + # A unique identifer we can use to associate our calling source + _uid = str(uuid4()) + def __init__(self, **kwargs): """ Asset Initialization diff --git a/apprise/plugins/NotifyAppriseAPI.py b/apprise/plugins/NotifyAppriseAPI.py new file mode 100644 index 00000000..b981f97a --- /dev/null +++ b/apprise/plugins/NotifyAppriseAPI.py @@ -0,0 +1,382 @@ +# -*- coding: utf-8 -*- +# +# Copyright (C) 2021 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 six +import requests +from json import dumps + +from .NotifyBase import NotifyBase +from ..URLBase import PrivacyMode +from ..common import NotifyType +from ..utils import parse_list +from ..utils import validate_regex +from ..AppriseLocale import gettext_lazy as _ + + +class NotifyAppriseAPI(NotifyBase): + """ + A wrapper for Apprise (Persistent) API Notifications + """ + + # The default descriptive name associated with the Notification + service_name = 'Apprise API' + + # The services URL + service_url = 'https://github.com/caronc/apprise-api' + + # The default protocol + protocol = 'apprise' + + # The default secure protocol + secure_protocol = 'apprises' + + # A URL that takes you to the setup/help of the specific protocol + setup_url = 'https://github.com/caronc/apprise/wiki/Notify_apprise_api' + + # Depending on the number of transactions/notifications taking place, this + # could take a while. 30 seconds should be enough to perform the task + socket_connect_timeout = 30.0 + + # Disable throttle rate for Apprise API requests since they are normally + # local anyway + request_rate_per_sec = 0.0 + + # Define object templates + templates = ( + '{schema}://{host}/{token}', + '{schema}://{host}:{port}/{token}', + '{schema}://{user}@{host}/{token}', + '{schema}://{user}@{host}:{port}/{token}', + '{schema}://{user}:{password}@{host}/{token}', + '{schema}://{user}:{password}@{host}:{port}/{token}', + ) + + # Define our tokens; these are the minimum tokens required required to + # be passed into this function (as arguments). The syntax appends any + # previously defined in the base package and builds onto them + template_tokens = dict(NotifyBase.template_tokens, **{ + 'host': { + 'name': _('Hostname'), + 'type': 'string', + 'required': True, + }, + 'port': { + 'name': _('Port'), + 'type': 'int', + 'min': 1, + 'max': 65535, + }, + 'user': { + 'name': _('Username'), + 'type': 'string', + }, + 'password': { + 'name': _('Password'), + 'type': 'string', + 'private': True, + }, + 'token': { + 'name': _('Token'), + 'type': 'string', + 'required': True, + 'private': True, + 'regex': (r'^[A-Z0-9_-]{1,32}$', 'i'), + }, + }) + + # Define our template arguments + template_args = dict(NotifyBase.template_args, **{ + 'tags': { + 'name': _('Tags'), + 'type': 'string', + }, + 'to': { + 'alias_of': 'token', + }, + }) + + # Define any kwargs we're using + template_kwargs = { + 'headers': { + 'name': _('HTTP Header'), + 'prefix': '+', + }, + } + + def __init__(self, token=None, tags=None, headers=None, **kwargs): + """ + Initialize Apprise API Object + + headers can be a dictionary of key/value pairs that you want to + additionally include as part of the server headers to post with + + """ + super(NotifyAppriseAPI, self).__init__(**kwargs) + + self.fullpath = kwargs.get('fullpath') + if not isinstance(self.fullpath, six.string_types): + self.fullpath = '/' + + self.token = validate_regex( + token, *self.template_tokens['token']['regex']) + if not self.token: + msg = 'The Apprise API token specified ({}) is invalid.'\ + .format(token) + self.logger.warning(msg) + raise TypeError(msg) + + # Build list of tags + self.__tags = parse_list(tags) + + self.headers = {} + if headers: + # Store our extra headers + self.headers.update(headers) + + return + + def url(self, privacy=False, *args, **kwargs): + """ + Returns the URL built dynamically based on specified arguments. + """ + + # Our URL parameters + params = self.url_parameters(privacy=privacy, *args, **kwargs) + + # Append our headers into our parameters + params.update({'+{}'.format(k): v for k, v in self.headers.items()}) + + if self.__tags: + params['tags'] = ','.join([x for x in self.__tags]) + + # Determine Authentication + auth = '' + if self.user and self.password: + auth = '{user}:{password}@'.format( + user=NotifyAppriseAPI.quote(self.user, safe=''), + password=self.pprint( + self.password, privacy, mode=PrivacyMode.Secret, safe=''), + ) + elif self.user: + auth = '{user}@'.format( + user=NotifyAppriseAPI.quote(self.user, safe=''), + ) + + default_port = 443 if self.secure else 80 + + fullpath = self.fullpath.strip('/') + return '{schema}://{auth}{hostname}{port}{fullpath}{token}' \ + '/?{params}'.format( + schema=self.secure_protocol + if self.secure else self.protocol, + auth=auth, + # never encode hostname since we're expecting it to be a + # valid one + hostname=self.host, + port='' if self.port is None or self.port == default_port + else ':{}'.format(self.port), + fullpath='/{}/'.format(NotifyAppriseAPI.quote( + fullpath, safe='/')) if fullpath else '/', + token=self.pprint(self.token, privacy, safe=''), + params=NotifyAppriseAPI.urlencode(params)) + + def send(self, body, title='', notify_type=NotifyType.INFO, **kwargs): + """ + Perform Apprise API Notification + """ + + headers = {} + # Apply any/all header over-rides defined + headers.update(self.headers) + + # prepare Apprise API Object + payload = { + # Apprise API Payload + 'title': title, + 'body': body, + 'type': notify_type, + 'format': self.notify_format, + } + + if self.__tags: + payload['tag'] = self.__tags + + auth = None + if self.user: + auth = (self.user, self.password) + + # Set our schema + schema = 'https' if self.secure else 'http' + + url = '%s://%s' % (schema, self.host) + if isinstance(self.port, int): + url += ':%d' % self.port + + fullpath = self.fullpath.strip('/') + url += '/{}/'.format(fullpath) if fullpath else '/' + url += 'notify/{}'.format(self.token) + + # Some entries can not be over-ridden + headers.update({ + 'User-Agent': self.app_id, + 'Content-Type': 'application/json', + # Pass our Source UUID4 Identifier + 'X-Apprise-ID': self.asset._uid, + # Pass our current recursion count to our upstream server + 'X-Apprise-Recursion-Count': str(self.asset._recursion + 1), + }) + + self.logger.debug('Apprise API POST URL: %s (cert_verify=%r)' % ( + url, self.verify_certificate, + )) + self.logger.debug('Apprise API Payload: %s' % str(payload)) + + # Always call throttle before any remote server i/o is made + self.throttle() + + try: + r = requests.post( + url, + data=dumps(payload), + headers=headers, + auth=auth, + verify=self.verify_certificate, + timeout=self.request_timeout, + ) + if r.status_code != requests.codes.ok: + # We had a problem + status_str = \ + NotifyAppriseAPI.http_response_code_lookup(r.status_code) + + self.logger.warning( + 'Failed to send Apprise API notification: ' + '{}{}error={}.'.format( + status_str, + ', ' if status_str else '', + r.status_code)) + + self.logger.debug('Response Details:\r\n{}'.format(r.content)) + + # Return; we're done + return False + + else: + self.logger.info('Sent Apprise API notification.') + + except requests.RequestException as e: + self.logger.warning( + 'A Connection error occurred sending Apprise API ' + 'notification to %s.' % self.host) + self.logger.debug('Socket Exception: %s' % str(e)) + + # Return; we're done + return False + + return True + + @staticmethod + def parse_native_url(url): + """ + Support http://hostname/notify/token and + http://hostname/path/notify/token + """ + + result = re.match( + r'^http(?Ps?)://(?P[A-Z0-9._-]+)' + r'(:(?P[0-9]+))?' + r'(?P/[^?]+?)?/notify/(?P[A-Z0-9_-]{1,32})/?' + r'(?P\?.+)?$', url, re.I) + + if result: + return NotifyAppriseAPI.parse_url( + '{schema}://{hostname}{port}{path}/{token}/{params}'.format( + schema=NotifyAppriseAPI.secure_protocol + if result.group('secure') else NotifyAppriseAPI.protocol, + hostname=result.group('hostname'), + port='' if not result.group('port') + else ':{}'.format(result.group('port')), + path='' if not result.group('path') + else result.group('path'), + token=result.group('token'), + params='' if not result.group('params') + else '?{}'.format(result.group('params')))) + + return None + + @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) + if not results: + # We're done early as we couldn't load the results + return results + + # Add our headers that the user can potentially over-ride if they wish + # to to our returned result set + results['headers'] = results['qsd+'] + if results['qsd-']: + results['headers'].update(results['qsd-']) + NotifyBase.logger.deprecate( + "minus (-) based Apprise API header tokens are being " + " removed; use the plus (+) symbol instead.") + + # Tidy our header entries by unquoting them + results['headers'] = \ + {NotifyAppriseAPI.unquote(x): NotifyAppriseAPI.unquote(y) + for x, y in results['headers'].items()} + + # Support the passing of tags in the URL + if 'tags' in results['qsd'] and len(results['qsd']['tags']): + results['tags'] = \ + NotifyAppriseAPI.parse_list(results['qsd']['tags']) + + # Support the 'to' & 'token' variable so that we can support rooms + # this way too. + if 'token' in results['qsd'] and len(results['qsd']['token']): + results['token'] = \ + NotifyAppriseAPI.unquote(results['qsd']['token']) + + elif 'to' in results['qsd'] and len(results['qsd']['to']): + results['token'] = NotifyAppriseAPI.unquote(results['qsd']['to']) + + else: + # Start with a list of path entries to work with + entries = NotifyAppriseAPI.split_path(results['fullpath']) + if entries: + # use our last entry found + results['token'] = entries[-1] + + # pop our last entry off + entries = entries[:-1] + + # re-assemble our full path + results['fullpath'] = '/'.join(entries) + + return results diff --git a/packaging/redhat/python-apprise.spec b/packaging/redhat/python-apprise.spec index 257fd6d6..2cf1155c 100644 --- a/packaging/redhat/python-apprise.spec +++ b/packaging/redhat/python-apprise.spec @@ -47,15 +47,15 @@ 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, ClickSend, DingTalk, Discord, E-Mail, Emby, Faast, FCM, Flock, Gitter, -Google Chat, Gotify, Growl, Home Assistant, IFTTT, Join, Kavenegar, KODI, -Kumulos, LaMetric, MacOSX, Mailgun, Mattermost, Matrix, Microsoft Windows, -Microsoft Teams, MessageBird, MQTT, MSG91, MyAndroid, Nexmo, Nextcloud, Notica, -Notifico, Office365, OneSignal, Opsgenie, ParsePlatform, PopcornNotify, Prowl, -Pushalot, PushBullet, Pushjet, Pushover, PushSafer, Reddit, Rocket.Chat, -SendGrid, SimplePush, Sinch, Slack, SMTP2Go, Spontit, SparkPost, Super Toasty, -Streamlabs, Stride, Syslog, Techulus Push, Telegram, Twilio, Twitter, Twist, XBMC, -XMPP, Webex Teams} +Apprise API, Boxcar, ClickSend, DingTalk, Discord, E-Mail, Emby, Faast, FCM, +Flock, Gitter, Google Chat, Gotify, Growl, Home Assistant, IFTTT, Join, +Kavenegar, KODI, Kumulos, LaMetric, MacOSX, Mailgun, Mattermost, Matrix, +Microsoft Windows, Microsoft Teams, MessageBird, MQTT, MSG91, MyAndroid, Nexmo, +Nextcloud, Notica, Notifico, Office365, OneSignal, Opsgenie, ParsePlatform, +PopcornNotify, Prowl, Pushalot, PushBullet, Pushjet, Pushover, PushSafer, +Reddit, Rocket.Chat, SendGrid, SimplePush, Sinch, Slack, SMTP2Go, Spontit, +SparkPost, Super Toasty, Streamlabs, Stride, Syslog, Techulus Push, Telegram, +Twilio, Twitter, Twist, XBMC, XMPP, Webex Teams} Name: python-%{pypi_name} Version: 0.9.5.1 diff --git a/test/test_config_base.py b/test/test_config_base.py index 5e057acb..f28d5f24 100644 --- a/test/test_config_base.py +++ b/test/test_config_base.py @@ -693,8 +693,8 @@ urls: # There were no include entries defined assert len(config) == 0 - # An asset we'll manipulate - asset = AppriseAsset() + # An asset we'll manipulate; set some system flags + asset = AppriseAsset(_uid="abc123", _recursion=1) # Global Tags result, config = ConfigBase.config_parse_yaml(""" @@ -705,6 +705,10 @@ asset: app_url: http://nuxref.com async_mode: no + # System flags should never get set + _uid: custom_id + _recursion: 100 + # Support setting empty values image_url_mask: image_url_logo: @@ -739,6 +743,10 @@ urls: assert asset.app_desc == "Apprise Test Notifications" assert asset.app_url == "http://nuxref.com" + # Verify our system flags retain only the value they were initialized to + assert asset._uid == "abc123" + assert asset._recursion == 1 + # Boolean types stay boolean assert asset.async_mode is False diff --git a/test/test_rest_plugins.py b/test/test_rest_plugins.py index b19843ec..cafcb43b 100644 --- a/test/test_rest_plugins.py +++ b/test/test_rest_plugins.py @@ -68,6 +68,131 @@ REQUEST_EXCEPTIONS = ( TEST_VAR_DIR = os.path.join(os.path.dirname(__file__), 'var') TEST_URLS = ( + ################################## + # NotifyAppriseAPI + ################################## + ('apprise://', { + # invalid url (not complete) + 'instance': None, + }), + # A a bad url + ('apprise://:@/', { + 'instance': None, + }), + # No token specified + ('apprise://localhost', { + 'instance': TypeError, + }), + # invalid token + ('apprise://localhost/!', { + 'instance': TypeError, + }), + # No token specified (whitespace is trimmed) + ('apprise://localhost/%%20', { + 'instance': TypeError, + }), + # A valid URL with Token + ('apprise://localhost/%s' % ('a' * 32), { + 'instance': plugins.NotifyAppriseAPI, + # Our expected url(privacy=True) startswith() response: + 'privacy_url': 'apprise://localhost/a...a/', + }), + # A valid URL with Token (using port) + ('apprise://localhost:8080/%s' % ('b' * 32), { + 'instance': plugins.NotifyAppriseAPI, + # Our expected url(privacy=True) startswith() response: + 'privacy_url': 'apprise://localhost:8080/b...b/', + }), + # A secure (https://) reference + ('apprises://localhost/%s' % ('c' * 32), { + 'instance': plugins.NotifyAppriseAPI, + # Our expected url(privacy=True) startswith() response: + 'privacy_url': 'apprises://localhost/c...c/', + }), + # Native URL suport (https) + ('https://example.com/path/notify/%s' % ('d' * 32), { + 'instance': plugins.NotifyAppriseAPI, + # Our expected url(privacy=True) startswith() response: + 'privacy_url': 'apprises://example.com/path/d...d/', + }), + # Native URL suport (http) + ('http://example.com/notify/%s' % ('d' * 32), { + 'instance': plugins.NotifyAppriseAPI, + # Our expected url(privacy=True) startswith() response: + 'privacy_url': 'apprise://example.com/d...d/', + }), + # support to= keyword + ('apprises://localhost/?to=%s' % ('e' * 32), { + 'instance': plugins.NotifyAppriseAPI, + 'privacy_url': 'apprises://localhost/e...e/', + }), + # support token= keyword (even when passed with to=, token over-rides) + ('apprise://localhost/?token=%s&to=%s' % ('f' * 32, 'abcd'), { + 'instance': plugins.NotifyAppriseAPI, + 'privacy_url': 'apprise://localhost/f...f/', + }), + # Test tags + ('apprise://localhost/?token=%s&tags=admin,team' % ('abcd'), { + 'instance': plugins.NotifyAppriseAPI, + 'privacy_url': 'apprise://localhost/a...d/', + }), + # Test Format string + ('apprise://user@localhost/mytoken0/?format=markdown', { + 'instance': plugins.NotifyAppriseAPI, + 'privacy_url': 'apprise://user@localhost/m...0/', + }), + ('apprise://user@localhost/mytoken1/', { + 'instance': plugins.NotifyAppriseAPI, + 'privacy_url': 'apprise://user@localhost/m...1/', + }), + ('apprise://localhost:8080/mytoken/', { + 'instance': plugins.NotifyAppriseAPI, + }), + ('apprise://user:pass@localhost:8080/mytoken2/', { + 'instance': plugins.NotifyAppriseAPI, + 'privacy_url': 'apprise://user:****@localhost:8080/m...2/', + }), + ('apprises://localhost/mytoken/', { + 'instance': plugins.NotifyAppriseAPI, + }), + ('apprises://user:pass@localhost/mytoken3/', { + 'instance': plugins.NotifyAppriseAPI, + # Our expected url(privacy=True) startswith() response: + 'privacy_url': 'apprises://user:****@localhost/m...3/', + }), + ('apprises://localhost:8080/mytoken4/', { + 'instance': plugins.NotifyAppriseAPI, + # Our expected url(privacy=True) startswith() response: + 'privacy_url': 'apprises://localhost:8080/m...4/', + }), + ('apprises://user:password@localhost:8080/mytoken5/', { + 'instance': plugins.NotifyAppriseAPI, + + # Our expected url(privacy=True) startswith() response: + 'privacy_url': 'apprises://user:****@localhost:8080/m...5/', + }), + ('apprises://localhost:8080/path?-HeaderKey=HeaderValue', { + 'instance': plugins.NotifyAppriseAPI, + }), + ('apprise://localhost/%s' % ('a' * 32), { + 'instance': plugins.NotifyAppriseAPI, + # force a failure + 'response': False, + 'requests_response_code': requests.codes.internal_server_error, + }), + ('apprise://localhost/%s' % ('a' * 32), { + 'instance': plugins.NotifyAppriseAPI, + # throw a bizzare code forcing us to fail to look it up + 'response': False, + 'requests_response_code': 999, + }), + ('apprise://localhost/%s' % ('a' * 32), { + 'instance': plugins.NotifyAppriseAPI, + # Throws a series of connection and transfer exceptions when this flag + # is set and tests that we gracfully handle them + 'test_requests_exceptions': True, + }), + ################################## # NotifyBoxcar ##################################