Pagertree Support (#817)

pull/825/head
Austin Miller 2 years ago committed by GitHub
parent f1996a1e57
commit d395d89a3b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

@ -49,6 +49,7 @@ Office365
OneSignal
Opsgenie
PagerDuty
PagerTree
ParsePlatform
PopcornNotify
Prowl

@ -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<br/>onesignal://TemplateID:AppID@APIKey/UserID<br/>onesignal://AppID@APIKey/#IncludeSegment<br/>onesignal://AppID@APIKey/Email
| [Opsgenie](https://github.com/caronc/apprise/wiki/Notify_opsgenie) | opsgenie:// | (TCP) 443 | opsgenie://APIKey<br/>opsgenie://APIKey/UserID<br/>opsgenie://APIKey/#Team<br/>opsgenie://APIKey/\*Schedule<br/>opsgenie://APIKey/^Escalation
| [PagerDuty](https://github.com/caronc/apprise/wiki/Notify_pagerduty) | pagerduty:// | (TCP) 443 | pagerduty://IntegrationKey@ApiKey<br/>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<br/>parseps://AppID:MasterKey@Hostname
| [PopcornNotify](https://github.com/caronc/apprise/wiki/Notify_popcornnotify) | popcorn:// | (TCP) 443 | popcorn://ApiKey/ToPhoneNo<br/>popcorn://ApiKey/ToPhoneNo1/ToPhoneNo2/ToPhoneNoN/<br/>popcorn://ApiKey/ToEmail<br/>popcorn://ApiKey/ToEmail1/ToEmail2/ToEmailN/<br/>popcorn://ApiKey/ToPhoneNo1/ToEmail1/ToPhoneNoN/ToEmailN
| [Prowl](https://github.com/caronc/apprise/wiki/Notify_prowl) | prowl:// | (TCP) 443 | prowl://apikey<br />prowl://apikey/providerkey

@ -0,0 +1,424 @@
# -*- coding: utf-8 -*-
# BSD 3-Clause License
#
# Apprise - Push Notification Library.
# Copyright (c) 2023, Chris Caron <lead2gold@gmail.com>
#
# 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

@ -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}

@ -0,0 +1,147 @@
# -*- coding: utf-8 -*-
# BSD 3-Clause License
#
# Apprise - Push Notification Library.
# Copyright (c) 2023, Chris Caron <lead2gold@gmail.com>
#
# 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=' ')
Loading…
Cancel
Save