From 61e9d7ce71950f3ca89cc724cb6df470d2639127 Mon Sep 17 00:00:00 2001 From: Chris Caron Date: Thu, 2 Jun 2022 19:40:31 -0400 Subject: [PATCH] Added PagerDuty Support (via event triggers) (#587) --- KEYWORDS | 1 + README.md | 1 + apprise/plugins/NotifyPagerDuty.py | 500 +++++++++++++++++++++++++++ packaging/redhat/python-apprise.spec | 10 +- test/test_plugin_pagerduty.py | 119 +++++++ 5 files changed, 626 insertions(+), 5 deletions(-) create mode 100644 apprise/plugins/NotifyPagerDuty.py create mode 100644 test/test_plugin_pagerduty.py diff --git a/KEYWORDS b/KEYWORDS index ed91fefd..6e5f0793 100644 --- a/KEYWORDS +++ b/KEYWORDS @@ -45,6 +45,7 @@ Ntfy Office365 OneSignal Opsgenie +PagerDuty ParsePlatform PopcornNotify Prowl diff --git a/README.md b/README.md index 11389da8..583a48ac 100644 --- a/README.md +++ b/README.md @@ -68,6 +68,7 @@ The table below identifies the services this tool supports and some example serv | [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 | [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 | [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/NotifyPagerDuty.py b/apprise/plugins/NotifyPagerDuty.py new file mode 100644 index 00000000..2f25bb0f --- /dev/null +++ b/apprise/plugins/NotifyPagerDuty.py @@ -0,0 +1,500 @@ +# -*- coding: utf-8 -*- +# +# Copyright (C) 2022 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. + +# API Refererence: +# - https://developer.pagerduty.com/api-reference/\ +# 368ae3d938c9e-send-an-event-to-pager-duty +# + +import requests +from json import dumps + +from .NotifyBase import NotifyBase +from ..URLBase import PrivacyMode +from ..common import NotifyType +from ..common import NotifyImageSize +from ..utils import validate_regex +from ..utils import parse_bool +from ..AppriseLocale import gettext_lazy as _ + + +class PagerDutySeverity(object): + """ + Defines the Pager Duty Severity Levels + """ + INFO = 'info' + + WARNING = 'warning' + + ERROR = 'error' + + CRITICAL = 'critical' + + +# Map all support Apprise Categories with the Pager Duty ones +PAGERDUTY_SEVERITY_MAP = { + NotifyType.INFO: PagerDutySeverity.INFO, + NotifyType.SUCCESS: PagerDutySeverity.INFO, + NotifyType.WARNING: PagerDutySeverity.WARNING, + NotifyType.FAILURE: PagerDutySeverity.CRITICAL, +} + + +# Priorities +class PagerDutyRegion(object): + US = 'us' + EU = 'eu' + + +# SparkPost APIs +PAGERDUTY_API_LOOKUP = { + PagerDutyRegion.US: 'https://events.pagerduty.com/v2/enqueue', + PagerDutyRegion.EU: 'https://events.eu.pagerduty.com/v2/enqueue', +} + +# A List of our regions we can use for verification +PAGERDUTY_REGIONS = ( + PagerDutyRegion.US, + PagerDutyRegion.EU, +) + + +class NotifyPagerDuty(NotifyBase): + """ + A wrapper for Pager Duty Notifications + """ + + # The default descriptive name associated with the Notification + service_name = 'Pager Duty' + + # The services URL + service_url = 'https://pagerduty.com/' + + # Secure Protocol + secure_protocol = 'pagerduty' + + # A URL that takes you to the setup/help of the specific protocol + setup_url = 'https://github.com/caronc/apprise/wiki/Notify_pagerduty' + + # We don't support titles for Pager Duty notifications + title_maxlen = 0 + + # Allows the user to specify the NotifyImageSize object; this is supported + # through the webhook + image_size = NotifyImageSize.XY_128 + + # Our event action type + event_action = 'trigger' + + # The default region to use if one isn't otherwise specified + default_region = PagerDutyRegion.US + + # Define object templates + templates = ( + '{schema}://{integrationkey}@{apikey}', + '{schema}://{integrationkey}@{apikey}/{source}', + '{schema}://{integrationkey}@{apikey}/{source}/{component}', + ) + + # Define our template tokens + template_tokens = dict(NotifyBase.template_tokens, **{ + 'apikey': { + 'name': _('API Key'), + 'type': 'string', + 'private': True, + 'required': True + }, + # Optional but triggers V2 API + 'integrationkey': { + 'name': _('Routing Key'), + 'type': 'string', + 'private': True, + 'required': True + }, + 'source': { + # Optional Source Identifier (preferably a FQDN) + 'name': _('Source'), + 'type': 'string', + 'default': 'Apprise', + }, + 'component': { + # Optional Component Identifier + 'name': _('Component'), + 'type': 'string', + 'default': 'Notification', + }, + }) + + # Define our template arguments + template_args = dict(NotifyBase.template_args, **{ + 'group': { + 'name': _('Group'), + 'type': 'string', + }, + 'class': { + 'name': _('Class'), + 'type': 'string', + 'map_to': 'class_id', + }, + 'click': { + 'name': _('Click'), + 'type': 'string', + }, + 'region': { + 'name': _('Region Name'), + 'type': 'choice:string', + 'values': PAGERDUTY_REGIONS, + 'default': PagerDutyRegion.US, + 'map_to': 'region_name', + }, + 'image': { + 'name': _('Include Image'), + 'type': 'bool', + 'default': True, + 'map_to': 'include_image', + }, + }) + + # Define any kwargs we're using + template_kwargs = { + 'details': { + 'name': _('Custom Details'), + 'prefix': '+', + }, + } + + def __init__(self, apikey, integrationkey=None, source=None, + component=None, group=None, class_id=None, + include_image=True, click=None, details=None, + region_name=None, **kwargs): + """ + Initialize Pager Duty Object + """ + super(NotifyPagerDuty, self).__init__(**kwargs) + + # Long-Lived Access token (generated from User Profile) + self.apikey = validate_regex(apikey) + if not self.apikey: + msg = 'An invalid Pager Duty API Key ' \ + '({}) was specified.'.format(apikey) + self.logger.warning(msg) + raise TypeError(msg) + + self.integration_key = validate_regex(integrationkey) + if not self.integration_key: + msg = 'An invalid Pager Duty Routing Key ' \ + '({}) was specified.'.format(integrationkey) + self.logger.warning(msg) + raise TypeError(msg) + + # An Optional Source + self.source = self.template_tokens['source']['default'] + if source: + self.source = validate_regex(source) + if not self.source: + msg = 'An invalid Pager Duty Notification Source ' \ + '({}) was specified.'.format(source) + self.logger.warning(msg) + raise TypeError(msg) + else: + self.component = self.template_tokens['source']['default'] + + # An Optional Component + self.component = self.template_tokens['component']['default'] + if component: + self.component = validate_regex(component) + if not self.component: + msg = 'An invalid Pager Duty Notification Component ' \ + '({}) was specified.'.format(component) + self.logger.warning(msg) + raise TypeError(msg) + else: + self.component = self.template_tokens['component']['default'] + + # Store our region + try: + self.region_name = self.default_region \ + if region_name is None else region_name.lower() + + if self.region_name not in PAGERDUTY_REGIONS: + # allow the outer except to handle this common response + raise + except: + # Invalid region specified + msg = 'The PagerDuty region specified ({}) is invalid.' \ + .format(region_name) + self.logger.warning(msg) + raise TypeError(msg) + + # A clickthrough option for notifications + self.click = click + + # Store Class ID if specified + self.class_id = class_id + + # Store Group if specified + self.group = group + + self.details = {} + if details: + # Store our extra details + self.details.update(details) + + # Display our Apprise Image + self.include_image = include_image + + return + + def send(self, body, title='', notify_type=NotifyType.INFO, **kwargs): + """ + Send our PagerDuty Notification + """ + + # Prepare our headers + headers = { + 'User-Agent': self.app_id, + 'Content-Type': 'application/json', + 'Authorization': 'Token token={}'.format(self.apikey), + } + + # Prepare our persistent_notification.create payload + payload = { + # Define our integration key + 'routing_key': self.integration_key, + + # Prepare our payload + 'payload': { + 'summary': body, + + # Set our severity + 'severity': PAGERDUTY_SEVERITY_MAP[notify_type], + + + # Our Alerting Source/Component + 'source': self.source, + 'component': self.component, + }, + 'client': self.app_id, + # Our Event Action + 'event_action': self.event_action, + } + + if self.group: + payload['payload']['group'] = self.group + + if self.class_id: + payload['payload']['class'] = self.class_id + + if self.click: + payload['links'] = [{ + "href": self.click, + }] + + # Acquire our image url if configured to do so + image_url = None if not self.include_image else \ + self.image_url(notify_type) + + if image_url: + payload['images'] = [{ + 'src': image_url, + 'alt': notify_type, + }] + + if self.details: + payload['payload']['custom_details'] = {} + # Apply any provided custom details + for k, v in self.details.items(): + payload['payload']['custom_details'][k] = v + + # Prepare our URL based on region + url = PAGERDUTY_API_LOOKUP[self.region_name] + + self.logger.debug('Pager Duty POST URL: %s (cert_verify=%r)' % ( + url, self.verify_certificate, + )) + self.logger.debug('Pager Duty Payload: %s' % str(payload)) + + # Always call throttle before any remote server i/o is made + self.throttle() + + try: + r = requests.post( + self.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 = \ + NotifyPagerDuty.http_response_code_lookup( + r.status_code) + + self.logger.warning( + 'Failed to send Pager Duty 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 Pager Duty notification.') + + except requests.RequestException as e: + self.logger.warning( + 'A Connection error occurred sending Pager Duty ' + '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 = { + 'region': self.region_name, + 'image': 'yes' if self.include_image else 'no', + } + if self.class_id: + params['class'] = self.class_id + + if self.group: + params['group'] = self.group + + if self.click is not None: + params['click'] = self.click + + # Append our custom entries our parameters + params.update({'+{}'.format(k): v for k, v in self.details.items()}) + + # Extend our parameters + params.update(self.url_parameters(privacy=privacy, *args, **kwargs)) + + url = '{schema}://{integration_key}@{apikey}/' \ + '{source}/{component}?{params}' + + return url.format( + schema=self.secure_protocol, + # never encode hostname since we're expecting it to be a valid one + integration_key=self.pprint( + self.integration_key, privacy, mode=PrivacyMode.Secret, + safe=''), + apikey=self.pprint( + self.apikey, privacy, mode=PrivacyMode.Secret, safe=''), + source=self.pprint( + self.source, privacy, safe=''), + component=self.pprint( + self.component, privacy, safe=''), + params=NotifyPagerDuty.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 + + # The 'apikey' makes it easier to use yaml configuration + if 'apikey' in results['qsd'] and len(results['qsd']['apikey']): + results['apikey'] = \ + NotifyPagerDuty.unquote(results['qsd']['apikey']) + else: + results['apikey'] = NotifyPagerDuty.unquote(results['host']) + + # The 'integrationkey' makes it easier to use yaml configuration + if 'integrationkey' in results['qsd'] and \ + len(results['qsd']['integrationkey']): + results['integrationkey'] = \ + NotifyPagerDuty.unquote(results['qsd']['integrationkey']) + else: + results['integrationkey'] = \ + NotifyPagerDuty.unquote(results['user']) + + if 'click' in results['qsd'] and len(results['qsd']['click']): + results['click'] = NotifyPagerDuty.unquote(results['qsd']['click']) + + if 'group' in results['qsd'] and len(results['qsd']['group']): + results['group'] = \ + NotifyPagerDuty.unquote(results['qsd']['group']) + + if 'class' in results['qsd'] and len(results['qsd']['class']): + results['class_id'] = \ + NotifyPagerDuty.unquote(results['qsd']['class']) + + # Acquire our full path + fullpath = NotifyPagerDuty.split_path(results['fullpath']) + + # Get our source + if 'source' in results['qsd'] and len(results['qsd']['source']): + results['source'] = \ + NotifyPagerDuty.unquote(results['qsd']['source']) + else: + results['source'] = fullpath.pop(0) if fullpath else None + + # Get our component + if 'component' in results['qsd'] and len(results['qsd']['component']): + results['component'] = \ + NotifyPagerDuty.unquote(results['qsd']['component']) + else: + results['component'] = fullpath.pop(0) if fullpath else None + + # Add our custom details key/value pairs that the user can potentially + # over-ride if they wish to to our returned result set and tidy + # entries by unquoting them + results['details'] = { + NotifyPagerDuty.unquote(x): NotifyPagerDuty.unquote(y) + for x, y in results['qsd+'].items()} + + if 'region' in results['qsd'] and len(results['qsd']['region']): + # Extract from name to associate with from address + results['region_name'] = \ + NotifyPagerDuty.unquote(results['qsd']['region']) + + # Include images with our message + results['include_image'] = \ + parse_bool(results['qsd'].get('image', True)) + + return results diff --git a/packaging/redhat/python-apprise.spec b/packaging/redhat/python-apprise.spec index de0a5ae4..6f202b32 100644 --- a/packaging/redhat/python-apprise.spec +++ b/packaging/redhat/python-apprise.spec @@ -52,11 +52,11 @@ Discord, E-Mail, Emby, Faast, FCM, Flock, Gitter, Google Chat, Gotify, Growl, Guilded, Home Assistant, IFTTT, Join, Kavenegar, KODI, Kumulos, LaMetric, MacOSX, Mailgun, Mattermost, Matrix, Microsoft Windows, Microsoft Teams, MessageBird, MQTT, MSG91, MyAndroid, Nexmo, Nextcloud, NextcloudTalk, Notica, -Notifico, ntfy, Office365, OneSignal, Opsgenie, ParsePlatform, PopcornNotify, -Prowl, Pushalot, PushBullet, Pushjet, Pushover, PushSafer, Reddit, -Rocket.Chat, SendGrid, ServerChan, Signal, SimplePush, Sinch, Slack, SMTP2Go, -Spontit, SparkPost, Super Toasty, Streamlabs, Stride, Syslog, Techulus Push, -Telegram, Twilio, Twitter, Twist, XBMC, XMPP, Webex Teams} +Notifico, ntfy, Office365, OneSignal, Opsgenie, PagerDuty, ParsePlatform, +PopcornNotify, Prowl, Pushalot, PushBullet, Pushjet, Pushover, PushSafer, +Reddit, Rocket.Chat, SendGrid, ServerChan, Signal, 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.8.3 diff --git a/test/test_plugin_pagerduty.py b/test/test_plugin_pagerduty.py new file mode 100644 index 00000000..fdf3c364 --- /dev/null +++ b/test/test_plugin_pagerduty.py @@ -0,0 +1,119 @@ +# -*- coding: utf-8 -*- +# +# Copyright (C) 2022 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. +from apprise import plugins +from helpers import AppriseURLTester + +# Disable logging for a cleaner testing output +import logging +logging.disable(logging.CRITICAL) + +# Our Testing URLs +apprise_url_tests = ( + ('pagerduty://', { + # No Access Token or Integration/Routing Key specified + 'instance': TypeError, + }), + ('pagerduty://%20@%20/', { + # invalid Access Token and Integration/Routing Key + 'instance': TypeError, + }), + ('pagerduty://%20/', { + # invalid Access Token; no Integration/Routing Key + 'instance': TypeError, + }), + ('pagerduty://%20@abcd/', { + # Invalid Integration/Routing Key (but valid Access Token) + 'instance': TypeError, + }), + ('pagerduty://myroutekey@myapikey/%20', { + # bad source + 'instance': TypeError, + }), + ('pagerduty://myroutekey@myapikey/mysource/%20', { + # bad component + 'instance': TypeError, + }), + ('pagerduty://myroutekey@myapikey?region=invalid', { + # invalid region + 'instance': TypeError, + }), + ('pagerduty://myroutekey@myapikey', { + # minimum requirements met + 'instance': plugins.NotifyPagerDuty, + + # Our expected url(privacy=True) startswith() response: + 'privacy_url': 'pagerduty://****@****/A...e/N...n?', + }), + ('pagerduty://myroutekey@myapikey?image=no', { + # minimum requirements met and disable images + 'instance': plugins.NotifyPagerDuty, + }), + ('pagerduty://myroutekey@myapikey?region=eu', { + # european region + 'instance': plugins.NotifyPagerDuty, + }), + # Custom values + ('pagerduty://myroutekey@myapikey?+key=value&+key2=value2', { + # minimum requirements and support custom key/value pairs + 'instance': plugins.NotifyPagerDuty, + }), + ('pagerduty://myroutekey@myapikey/mysource/mycomponent', { + # a valid url + 'instance': plugins.NotifyPagerDuty, + + # Our expected url(privacy=True) startswith() response: + 'privacy_url': 'pagerduty://****@****/m...e/m...t?', + }), + ('pagerduty://routekey@apikey/ms/mc?group=mygroup&class=myclass', { + # class/group testing + 'instance': plugins.NotifyPagerDuty, + }), + ('pagerduty://?integrationkey=r&apikey=a&source=s&component=c' + '&group=g&class=c&image=no&click=http://localhost', { + # all parameters + 'instance': plugins.NotifyPagerDuty}), + ('pagerduty://somerkey@someapikey/bizzare/code', { + 'instance': plugins.NotifyPagerDuty, + # throw a bizzare code forcing us to fail to look it up + 'response': False, + 'requests_response_code': 999, + }), + ('pagerduty://myroutekey@myapikey/mysource/mycomponent', { + 'instance': plugins.NotifyPagerDuty, + # Throws a series of connection and transfer exceptions when this flag + # is set and tests that we gracfully handle them + 'test_requests_exceptions': True, + }), +) + + +def test_plugin_pagerduty_urls(): + """ + NotifyPagerDuty() Apprise URLs + + """ + + # Run our general tests + AppriseURLTester(tests=apprise_url_tests).run_all()