From d395d89a3bada9a11c987b86912fa30d3288e05a Mon Sep 17 00:00:00 2001 From: Austin Miller Date: Mon, 13 Feb 2023 18:01:18 -0700 Subject: [PATCH] Pagertree Support (#817) --- KEYWORDS | 1 + README.md | 1 + apprise/plugins/NotifyPagerTree.py | 424 +++++++++++++++++++++++++++ packaging/redhat/python-apprise.spec | 6 +- test/test_plugin_pagertree.py | 147 ++++++++++ 5 files changed, 576 insertions(+), 3 deletions(-) create mode 100644 apprise/plugins/NotifyPagerTree.py create mode 100644 test/test_plugin_pagertree.py diff --git a/KEYWORDS b/KEYWORDS index b46dbf31..1c0157ed 100644 --- a/KEYWORDS +++ b/KEYWORDS @@ -49,6 +49,7 @@ Office365 OneSignal Opsgenie PagerDuty +PagerTree ParsePlatform PopcornNotify Prowl diff --git a/README.md b/README.md index d0b39984..345390d7 100644 --- a/README.md +++ b/README.md @@ -93,6 +93,7 @@ The table below identifies the services this tool supports and some example serv | [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 | [Opsgenie](https://github.com/caronc/apprise/wiki/Notify_opsgenie) | opsgenie:// | (TCP) 443 | opsgenie://APIKey
opsgenie://APIKey/UserID
opsgenie://APIKey/#Team
opsgenie://APIKey/\*Schedule
opsgenie://APIKey/^Escalation | [PagerDuty](https://github.com/caronc/apprise/wiki/Notify_pagerduty) | pagerduty:// | (TCP) 443 | pagerduty://IntegrationKey@ApiKey
pagerduty://IntegrationKey@ApiKey/Source/Component +| [PagerTree](https://github.com/caronc/apprise/wiki/Notify_pagertree) | pagertree:// | (TCP) 443 | pagertree://integration_id | [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/NotifyPagerTree.py b/apprise/plugins/NotifyPagerTree.py new file mode 100644 index 00000000..65a19f61 --- /dev/null +++ b/apprise/plugins/NotifyPagerTree.py @@ -0,0 +1,424 @@ +# -*- 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. + +import requests +from json import dumps + +from uuid import uuid4 + +from .NotifyBase import NotifyBase +from ..common import NotifyType +from ..utils import parse_list +from ..utils import validate_regex +from ..AppriseLocale import gettext_lazy as _ + + +# Actions +class PagerTreeAction: + CREATE = 'create' + ACKNOWLEDGE = 'acknowledge' + RESOLVE = 'resolve' + + +# Urgencies +class PagerTreeUrgency: + SILENT = "silent" + LOW = "low" + MEDIUM = "medium" + HIGH = "high" + CRITICAL = "critical" + + +PAGERTREE_ACTIONS = { + PagerTreeAction.CREATE: 'create', + PagerTreeAction.ACKNOWLEDGE: 'acknowledge', + PagerTreeAction.RESOLVE: 'resolve', +} + +PAGERTREE_URGENCIES = { + # Note: This also acts as a reverse lookup mapping + PagerTreeUrgency.SILENT: 'silent', + PagerTreeUrgency.LOW: 'low', + PagerTreeUrgency.MEDIUM: 'medium', + PagerTreeUrgency.HIGH: 'high', + PagerTreeUrgency.CRITICAL: 'critical', +} +# Extend HTTP Error Messages +PAGERTREE_HTTP_ERROR_MAP = { + 402: 'Payment Required - Please subscribe or upgrade', + 403: 'Forbidden - Blocked', + 404: 'Not Found - Invalid Integration ID', + 405: 'Method Not Allowed - Integration Disabled', + 429: 'Too Many Requests - Rate Limit Exceeded', +} + + +class NotifyPagerTree(NotifyBase): + """ + A wrapper for PagerTree Notifications + """ + + # The default descriptive name associated with the Notification + service_name = 'PagerTree' + + # The services URL + service_url = 'https://pagertree.com/' + + # All PagerTree requests are secure + secure_protocol = 'pagertree' + + # A URL that takes you to the setup/help of the specific protocol + setup_url = 'https://github.com/caronc/apprise/wiki/Notify_pagertree' + + # PagerTree uses the http protocol with JSON requests + notify_url = 'https://api.pagertree.com/integration/{}' + + # Define object templates + templates = ( + '{schema}://{integration}', + ) + + # Define our template tokens + template_tokens = dict(NotifyBase.template_tokens, **{ + 'integration': { + 'name': _('Integration ID'), + 'type': 'string', + 'private': True, + 'required': True, + } + }) + + # Define our template arguments + template_args = dict(NotifyBase.template_args, **{ + 'action': { + 'name': _('Action'), + 'type': 'choice:string', + 'values': PAGERTREE_ACTIONS, + 'default': PagerTreeAction.CREATE, + }, + 'thirdparty': { + 'name': _('Third Party ID'), + 'type': 'string', + }, + 'urgency': { + 'name': _('Urgency'), + 'type': 'choice:string', + 'values': PAGERTREE_URGENCIES, + }, + 'tags': { + 'name': _('Tags'), + 'type': 'string', + }, + }) + + # Define any kwargs we're using + template_kwargs = { + 'headers': { + 'name': _('HTTP Header'), + 'prefix': '+', + }, + 'payload_extras': { + 'name': _('Payload Extras'), + 'prefix': ':', + }, + 'meta_extras': { + 'name': _('Meta Extras'), + 'prefix': '-', + }, + } + + def __init__(self, integration, action=None, thirdparty=None, + urgency=None, tags=None, headers=None, + payload_extras=None, meta_extras=None, **kwargs): + """ + Initialize PagerTree Object + """ + super().__init__(**kwargs) + + # Integration ID (associated with account) + self.integration = \ + validate_regex(integration, r'^int_[a-zA-Z0-9\-_]{7,14}$') + if not self.integration: + msg = 'An invalid PagerTree Integration ID ' \ + '({}) was specified.'.format(integration) + self.logger.warning(msg) + raise TypeError(msg) + + # thirdparty (optional, in case they want to pass the + # acknowledge or resolve action) + self.thirdparty = None + if thirdparty: + # An id was specified, we want to validate it + self.thirdparty = validate_regex(thirdparty) + if not self.thirdparty: + msg = 'An invalid PagerTree third party ID ' \ + '({}) was specified.'.format(thirdparty) + self.logger.warning(msg) + raise TypeError(msg) + + self.headers = {} + if headers: + # Store our extra headers + self.headers.update(headers) + + self.payload_extras = {} + if payload_extras: + # Store our extra payload entries + self.payload_extras.update(payload_extras) + + self.meta_extras = {} + if meta_extras: + # Store our extra payload entries + self.meta_extras.update(meta_extras) + + # Setup our action + self.action = NotifyPagerTree.template_args['action']['default'] \ + if action not in PAGERTREE_ACTIONS else \ + PAGERTREE_ACTIONS[action] + + # Setup our urgency + self.urgency = \ + None if urgency not in PAGERTREE_URGENCIES else \ + PAGERTREE_URGENCIES[urgency] + + # Any optional tags to attach to the notification + self.__tags = parse_list(tags) + + return + + def send(self, body, title='', notify_type=NotifyType.INFO, **kwargs): + """ + Perform PagerTree Notification + """ + + # Prepare our headers + headers = { + 'User-Agent': self.app_id, + 'Content-Type': 'application/json', + } + + # Apply any/all header over-rides defined + # For things like PagerTree Token + headers.update(self.headers) + + # prepare JSON Object + payload = { + # Generate an ID (unless one was explicitly forced to be used) + 'id': self.thirdparty if self.thirdparty else str(uuid4()), + 'event_type': self.action, + } + + if self.action == PagerTreeAction.CREATE: + payload['title'] = title if title else self.app_desc + payload['description'] = body + + payload['meta'] = self.meta_extras + payload['tags'] = self.__tags + + if self.urgency is not None: + payload['urgency'] = self.urgency + + # Apply any/all payload over-rides defined + payload.update(self.payload_extras) + + # Prepare our URL based on integration + notify_url = self.notify_url.format(self.integration) + + self.logger.debug('PagerTree POST URL: %s (cert_verify=%r)' % ( + notify_url, self.verify_certificate, + )) + self.logger.debug('PagerTree Payload: %s' % str(payload)) + + # Always call throttle before any remote server i/o is made + self.throttle() + + try: + r = requests.post( + 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.created, + requests.codes.accepted): + # We had a problem + status_str = \ + NotifyPagerTree.http_response_code_lookup( + r.status_code) + + self.logger.warning( + 'Failed to send PagerTree 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 PagerTree notification.') + + except requests.RequestException as e: + self.logger.warning( + 'A Connection error occurred sending PagerTree ' + 'notification to %s.' % self.host) + self.logger.debug('Socket Exception: %s' % str(e)) + + # Return; we're done + return False + + return True + + def url(self, privacy=False, *args, **kwargs): + """ + Returns the URL built dynamically based on specified arguments. + """ + + # Define any URL parameters + params = { + 'action': self.action, + } + + if self.thirdparty: + params['tid'] = self.thirdparty + + if self.urgency: + params['urgency'] = self.urgency + + if self.__tags: + params['tags'] = ','.join([x for x in self.__tags]) + + # Extend our parameters + params.update(self.url_parameters(privacy=privacy, *args, **kwargs)) + + # Headers prefixed with a '+' sign + # Append our headers into our parameters + params.update({'+{}'.format(k): v for k, v in self.headers.items()}) + + # Meta: {} prefixed with a '-' sign + # Append our meta extras into our parameters + params.update( + {'-{}'.format(k): v for k, v in self.meta_extras.items()}) + + # Payload body extras prefixed with a ':' sign + # Append our payload extras into our parameters + params.update( + {':{}'.format(k): v for k, v in self.payload_extras.items()}) + + return '{schema}://{integration}?{params}'.format( + schema=self.secure_protocol, + # never encode hostname since we're expecting it to be a valid one + integration=self.pprint(self.integration, privacy, safe=''), + params=NotifyPagerTree.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 + + # Add our headers that the user can potentially over-ride if they wish + # to to our returned result set and tidy entries by unquoting them + results['headers'] = { + NotifyPagerTree.unquote(x): NotifyPagerTree.unquote(y) + for x, y in results['qsd+'].items() + } + + # store any additional payload extra's defined + results['payload_extras'] = { + NotifyPagerTree.unquote(x): NotifyPagerTree.unquote(y) + for x, y in results['qsd:'].items() + } + + # store any additional meta extra's defined + results['meta_extras'] = { + NotifyPagerTree.unquote(x): NotifyPagerTree.unquote(y) + for x, y in results['qsd-'].items() + } + + # Integration ID + if 'id' in results['qsd'] and len(results['qsd']['id']): + # Shortened version of integration id + results['integration'] = \ + NotifyPagerTree.unquote(results['qsd']['id']) + + elif 'integration' in results['qsd'] and \ + len(results['qsd']['integration']): + results['integration'] = \ + NotifyPagerTree.unquote(results['qsd']['integration']) + + else: + results['integration'] = \ + NotifyPagerTree.unquote(results['host']) + + # Set our thirdparty + + if 'tid' in results['qsd'] and len(results['qsd']['tid']): + # Shortened version of thirdparty + results['thirdparty'] = \ + NotifyPagerTree.unquote(results['qsd']['tid']) + + elif 'thirdparty' in results['qsd'] and \ + len(results['qsd']['thirdparty']): + results['thirdparty'] = \ + NotifyPagerTree.unquote(results['qsd']['thirdparty']) + + # Set our urgency + if 'action' in results['qsd'] and \ + len(results['qsd']['action']): + results['action'] = \ + NotifyPagerTree.unquote(results['qsd']['action']) + + # Set our urgency + if 'urgency' in results['qsd'] and len(results['qsd']['urgency']): + results['urgency'] = \ + NotifyPagerTree.unquote(results['qsd']['urgency']) + + # Set our tags + if 'tags' in results['qsd'] and len(results['qsd']['tags']): + results['tags'] = \ + parse_list(NotifyPagerTree.unquote(results['qsd']['tags'])) + + return results diff --git a/packaging/redhat/python-apprise.spec b/packaging/redhat/python-apprise.spec index faead731..93195609 100644 --- a/packaging/redhat/python-apprise.spec +++ b/packaging/redhat/python-apprise.spec @@ -50,9 +50,9 @@ Gotify, Growl, Guilded, Home Assistant, IFTTT, Join, Kavenegar, KODI, Kumulos, LaMetric, Line, MacOSX, Mailgun, Mattermost, Matrix, Microsoft Windows, Mastodon, Microsoft Teams, MessageBird, MQTT, MSG91, MyAndroid, Nexmo, Nextcloud, NextcloudTalk, Notica, Notifico, ntfy, Office365, OneSignal, -Opsgenie, PagerDuty, ParsePlatform, PopcornNotify, Prowl, Pushalot, PushBullet, -Pushjet, Pushover, PushSafer, Reddit, Rocket.Chat, SendGrid, ServerChan, Signal, -SimplePush, Sinch, Slack, SMSEagle, SMTP2Go, Spontit, SparkPost, Super Toasty, +Opsgenie, PagerDuty, PagerTree, ParsePlatform, PopcornNotify, Prowl, Pushalot, +PushBullet, Pushjet, Pushover, PushSafer, Reddit, Rocket.Chat, SendGrid, ServerChan, +Signal, SimplePush, Sinch, Slack, SMSEagle, SMTP2Go, Spontit, SparkPost, Super Toasty, Streamlabs, Stride, Syslog, Techulus Push, Telegram, Twilio, Twitter, Twist, XBMC, Voipms, Vonage, Webex Teams} diff --git a/test/test_plugin_pagertree.py b/test/test_plugin_pagertree.py new file mode 100644 index 00000000..16cf87cf --- /dev/null +++ b/test/test_plugin_pagertree.py @@ -0,0 +1,147 @@ +# -*- 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. + +import requests +from unittest import mock +import pytest + +from apprise.plugins.NotifyPagerTree import NotifyPagerTree +from helpers import AppriseURLTester + +# Disable logging for a cleaner testing output +import logging +logging.disable(logging.CRITICAL) + +# a test UUID we can use +INTEGRATION_ID = 'int_xxxxxxxxxxx' + +# Our Testing URLs +apprise_url_tests = ( + ('pagertree://', { + # Missing Integration ID + 'instance': TypeError, + }), + # Invalid Integration ID + ('pagertree://%s' % ('+' * 24), { + 'instance': TypeError, + }), + # Minimum requirements met + ('pagertree://%s' % INTEGRATION_ID, { + 'instance': NotifyPagerTree, + + # Our expected url(privacy=True) startswith() response: + 'privacy_url': 'pagertree://i...x?', + }), + # change the integration id + ('pagertree://%s?integration=int_yyyyyyyyyy' % INTEGRATION_ID, { + 'instance': NotifyPagerTree, + + # Our expected url(privacy=True) startswith() response: + 'privacy_url': 'pagertree://i...y?', + }), + # entries specified on the URL will over-ride the host (integration id) + ('pagertree://%s?id=int_zzzzzzzzzz' % INTEGRATION_ID, { + 'instance': NotifyPagerTree, + + # Our expected url(privacy=True) startswith() response: + 'privacy_url': 'pagertree://i...z?', + }), + # Integration ID + bad url + ('pagertree://:@/', { + 'instance': TypeError, + }), + ('pagertree://%s' % INTEGRATION_ID, { + 'instance': NotifyPagerTree, + # force a failure + 'response': False, + 'requests_response_code': requests.codes.internal_server_error, + }), + ('pagertree://%s' % INTEGRATION_ID, { + 'instance': NotifyPagerTree, + # throw a bizzare code forcing us to fail to look it up + 'response': False, + 'requests_response_code': 999, + }), + ('pagertree://%s' % INTEGRATION_ID, { + 'instance': NotifyPagerTree, + # Throws a series of connection and transfer exceptions when this flag + # is set and tests that we gracfully handle them + 'test_requests_exceptions': True, + }), + ('pagertree://%s?urgency=low' % INTEGRATION_ID, { + # urgency override + 'instance': NotifyPagerTree, + }), + ('pagertree://?id=%s&urgency=low' % INTEGRATION_ID, { + # urgency override and id= (for integration) + 'instance': NotifyPagerTree, + }), + ('pagertree://%s?tags=production,web' % INTEGRATION_ID, { + # tags + 'instance': NotifyPagerTree, + }), + ('pagertree://%s?action=resolve&thirdparty=123' % INTEGRATION_ID, { + # test resolve + 'instance': NotifyPagerTree, + }), + # Custom values + ('pagertree://%s?+pagertree-token=123&:env=prod&-m=v' % INTEGRATION_ID, { + # minimum requirements and support custom key/value pairs + 'instance': NotifyPagerTree, + }), +) + + +def test_plugin_pagertree_urls(): + """ + NotifyPagerTree() Apprise URLs + + """ + + # Run our general tests + AppriseURLTester(tests=apprise_url_tests).run_all() + + +@mock.patch('requests.post') +def test_plugin_pagertree_general(mock_post): + """ + NotifyPagerTree() General Checks + + """ + + # Prepare Mock + mock_post.return_value = requests.Request() + mock_post.return_value.status_code = requests.codes.ok + + # Invalid thirdparty id + with pytest.raises(TypeError): + NotifyPagerTree(integration=INTEGRATION_ID, thirdparty=' ')