From aee893152c92dea570e42226d5fe062235365b73 Mon Sep 17 00:00:00 2001 From: Chris Caron Date: Sun, 13 Oct 2019 13:59:02 -0400 Subject: [PATCH] Notifico Support (#163) --- apprise/plugins/NotifyNotifico.py | 378 +++++++++++++++++++++++++++ packaging/redhat/python-apprise.spec | 6 +- setup.cfg | 2 +- setup.py | 8 +- test/test_rest_plugins.py | 80 ++++++ 5 files changed, 466 insertions(+), 8 deletions(-) create mode 100644 apprise/plugins/NotifyNotifico.py diff --git a/apprise/plugins/NotifyNotifico.py b/apprise/plugins/NotifyNotifico.py new file mode 100644 index 00000000..c76180ff --- /dev/null +++ b/apprise/plugins/NotifyNotifico.py @@ -0,0 +1,378 @@ +# -*- coding: utf-8 -*- +# +# Copyright (C) 2019 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. + +# Notifico allows you to relay notifications into IRC channels. +# +# 1. visit https://n.tkte.ch and sign up for an account +# 2. create a project; either manually or sync with github +# 3. from within the project, you can create a message hook +# +# the URL will look something like this: +# https://n.tkte.ch/h/2144/uJmKaBW9WFk42miB146ci3Kj +# ^ ^ +# | | +# project id message hook +# +# This plugin also supports taking the URL (as identified above) directly +# as well. + +import re +import requests + +from .NotifyBase import NotifyBase +from ..common import NotifyType +from ..utils import parse_bool +from ..utils import validate_regex +from ..AppriseLocale import gettext_lazy as _ + + +class NotificoFormat(object): + # Resets all formating + Reset = '\x0F' + + # Formatting + Bold = '\x02' + Italic = '\x1D' + Underline = '\x1F' + BGSwap = '\x16' + + +class NotificoColor(object): + # Resets Color + Reset = '\x03' + + # Colors + White = '\x0300' + Black = '\x0301' + Blue = '\x0302' + Green = '\x0303' + Red = '\x0304' + Brown = '\x0305' + Purple = '\x0306' + Orange = '\x0307' + Yellow = '\x0308', + LightGreen = '\x0309' + Teal = '\x0310' + LightCyan = '\x0311' + LightBlue = '\x0312' + Violet = '\x0313' + Grey = '\x0314' + LightGrey = '\x0315' + + +class NotifyNotifico(NotifyBase): + """ + A wrapper for Notifico Notifications + """ + + # The default descriptive name associated with the Notification + service_name = 'Notifico' + + # The services URL + service_url = 'https://n.tkte.ch' + + # The default protocol + protocol = 'notifico' + + # The default secure protocol + secure_protocol = 'notifico' + + # A URL that takes you to the setup/help of the specific protocol + setup_url = 'https://github.com/caronc/apprise/wiki/Notify_notifico' + + # Plain Text Notification URL + notify_url = 'https://n.tkte.ch/h/{proj}/{hook}' + + # The title is not used + title_maxlen = 0 + + # The maximum allowable characters allowed in the body per message + body_maxlen = 512 + + # Define object templates + templates = ( + '{schema}://{project_id}/{msghook}', + ) + + # Define our template arguments + template_tokens = dict(NotifyBase.template_tokens, **{ + # The Project ID is found as the first part of the URL + # /1234/........................ + 'project_id': { + 'name': _('Project ID'), + 'type': 'string', + 'required': True, + 'private': True, + 'regex': (r'^[0-9]+$', ''), + }, + # The Message Hook follows the Project ID + # /..../AbCdEfGhIjKlMnOpQrStUvWX + 'msghook': { + 'name': _('Message Hook'), + 'type': 'string', + 'required': True, + 'private': True, + 'regex': (r'^[a-z0-9]+$', 'i'), + }, + }) + + # Define our template arguments + template_args = dict(NotifyBase.template_args, **{ + # You can optionally pass IRC colors into + 'color': { + 'name': _('IRC Colors'), + 'type': 'bool', + 'default': True, + }, + + # You can optionally pass IRC color into + 'prefix': { + 'name': _('Prefix'), + 'type': 'bool', + 'default': True, + }, + }) + + def __init__(self, project_id, msghook, color=True, prefix=True, + **kwargs): + """ + Initialize Notifico Object + """ + super(NotifyNotifico, self).__init__(**kwargs) + + # Assign our message hook + self.project_id = validate_regex( + project_id, *self.template_tokens['project_id']['regex']) + if not self.project_id: + msg = 'An invalid Notifico Project ID ' \ + '({}) was specified.'.format(project_id) + self.logger.warning(msg) + raise TypeError(msg) + + # Assign our message hook + self.msghook = validate_regex( + msghook, *self.template_tokens['msghook']['regex']) + if not self.msghook: + msg = 'An invalid Notifico Message Token ' \ + '({}) was specified.'.format(msghook) + self.logger.warning(msg) + raise TypeError(msg) + + # Prefix messages with a [?] where ? identifies the message type + # such as if it's an error, warning, info, or success + self.prefix = prefix + + # Send colors + self.color = color + + # Prepare our notification URL now: + self.api_url = self.notify_url.format( + proj=self.project_id, + hook=self.msghook, + ) + return + + def url(self, privacy=False, *args, **kwargs): + """ + Returns the URL built dynamically based on specified arguments. + """ + + # Define any arguments set + args = { + 'format': self.notify_format, + 'overflow': self.overflow_mode, + 'verify': 'yes' if self.verify_certificate else 'no', + 'color': 'yes' if self.color else 'no', + 'prefix': 'yes' if self.prefix else 'no', + } + + return '{schema}://{proj}/{hook}/?{args}'.format( + schema=self.secure_protocol, + proj=self.pprint(self.project_id, privacy, safe=''), + hook=self.pprint(self.msghook, privacy, safe=''), + args=NotifyNotifico.urlencode(args), + ) + + def send(self, body, title='', notify_type=NotifyType.INFO, **kwargs): + """ + wrapper to _send since we can alert more then one channel + """ + + # prepare our headers + headers = { + 'User-Agent': self.app_id, + 'Content-Type': 'application/x-www-form-urlencoded; charset=utf-8', + } + + # Prepare our IRC Prefix + color = '' + token = '' + if notify_type == NotifyType.INFO: + color = NotificoColor.Teal + token = 'i' + + elif notify_type == NotifyType.SUCCESS: + color = NotificoColor.LightGreen + token = '✔' + + elif notify_type == NotifyType.WARNING: + color = NotificoColor.Orange + token = '!' + + elif notify_type == NotifyType.FAILURE: + color = NotificoColor.Red + token = '✗' + + if self.color: + # Colors were specified, make sure we capture and correctly + # allow them to exist inline in the message + # \g<1> is less ambigious than \1 + body = re.sub(r'\\x03(\d{0,2})', '\x03\g<1>', body) + + else: + # no colors specified, make sure we strip out any colors found + # to make the string read-able + body = re.sub(r'\\x03(\d{1,2}(,[0-9]{1,2})?)?', '', body) + + # Prepare our payload + payload = { + 'payload': body if not self.prefix + else '{}[{}]{} {}{}{}: {}{}'.format( + # Token [?] at the head + color if self.color else '', + token, + NotificoColor.Reset if self.color else '', + # App ID + NotificoFormat.Bold if self.color else '', + self.app_id, + NotificoFormat.Reset if self.color else '', + # Message Body + body, + # Reset + NotificoFormat.Reset if self.color else '', + ), + } + + self.logger.debug('Notifico GET URL: %s (cert_verify=%r)' % ( + self.api_url, self.verify_certificate)) + self.logger.debug('Notifico Payload: %s' % str(payload)) + + # Always call throttle before any remote server i/o is made + self.throttle() + + try: + r = requests.get( + self.api_url, + params=payload, + headers=headers, + verify=self.verify_certificate, + ) + if r.status_code != requests.codes.ok: + # We had a problem + status_str = \ + NotifyNotifico.http_response_code_lookup(r.status_code) + + self.logger.warning( + 'Failed to send Notifico 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 Notifico notification.') + + except requests.RequestException as e: + self.logger.warning( + 'A Connection error occured sending Notifico ' + 'notification.') + self.logger.debug('Socket Exception: %s' % str(e)) + + # Return; we're done + return False + + return True + + @staticmethod + def parse_url(url): + """ + Parses the URL and returns enough arguments that can allow + us to substantiate this object. + + """ + + results = NotifyBase.parse_url(url) + if not results: + # We're done early as we couldn't load the results + return results + + # The first token is stored in the hostname + results['project_id'] = NotifyNotifico.unquote(results['host']) + + # Get Message Hook + try: + results['msghook'] = NotifyNotifico.split_path( + results['fullpath'])[0] + + except IndexError: + results['msghook'] = None + + # Include Color + results['color'] = \ + parse_bool(results['qsd'].get('color', True)) + + # Include Prefix + results['prefix'] = \ + parse_bool(results['qsd'].get('prefix', True)) + + return results + + @staticmethod + def parse_native_url(url): + """ + Support https://n.tkte.ch/h/PROJ_ID/MESSAGE_HOOK/ + """ + + result = re.match( + r'^https?://n\.tkte\.ch/h/' + r'(?P[0-9]+)/' + r'(?P[A-Z0-9]+)/?' + r'(?P\?.+)?$', url, re.I) + + if result: + return NotifyNotifico.parse_url( + '{schema}://{proj}/{hook}/{args}'.format( + schema=NotifyNotifico.secure_protocol, + proj=result.group('proj'), + hook=result.group('hook'), + args='' if not result.group('args') + else result.group('args'))) + + return None diff --git a/packaging/redhat/python-apprise.spec b/packaging/redhat/python-apprise.spec index 70235d02..b6f9d76a 100644 --- a/packaging/redhat/python-apprise.spec +++ b/packaging/redhat/python-apprise.spec @@ -49,9 +49,9 @@ it easy to access: Boxcar, ClickSend, Discord, E-Mail, Emby, Faast, Flock, Gitter, Gotify, Growl, IFTTT, Join, KODI, Kumulos, Mailgun, MatterMost, Matrix, Microsoft Windows -Notifications, Microsoft Teams, MessageBird, MSG91, Nexmo, Notify MyAndroid, -Prowl, Pushalot, PushBullet, Pushjet, Pushover, Rocket.Chat, SendGrid, -SimplePush, Slack, Super Toasty, Stride, Syslog, Techulus Push, Telegram, +Notifications, Microsoft Teams, MessageBird, MSG91, Nexmo, Notifico, +Notify MyAndroid, Prowl, Pushalot, PushBullet, Pushjet, Pushover, Rocket.Chat, +SendGrid, SimplePush, Slack, Super Toasty, Stride, Syslog, Techulus Push, Telegram, Twilio, Twitter, Twist, XBMC, XMPP, Webex Teams} Name: python-%{pypi_name} diff --git a/setup.cfg b/setup.cfg index 3a773551..6738cb7c 100644 --- a/setup.cfg +++ b/setup.cfg @@ -8,7 +8,7 @@ license_file = LICENSE [flake8] # We exclude packages we don't maintain exclude = .eggs,.tox,gntp -ignore = E722,W503,W504 +ignore = E722,W503,W504,W605 statistics = true builtins = _ diff --git a/setup.py b/setup.py index 61ddc4dc..0a3d014b 100755 --- a/setup.py +++ b/setup.py @@ -71,10 +71,10 @@ setup( url='https://github.com/caronc/apprise', keywords='Push Notifications Alerts Email AWS SNS Boxcar ClickSend ' 'Discord Dbus Emby Faast Flock Gitter Gnome Gotify Growl IFTTT Join ' - 'KODI Kumulos Mailgun Matrix Mattermost MessageBird MSG91 Nexmo Prowl ' - 'PushBullet Pushjet Pushed Pushover Rocket.Chat Ryver SendGrid ' - 'SimplePush Slack Stride Syslog Techulus Push Telegram Twilio Twist ' - 'Twitter XBMC Microsoft MSTeams Windows Webex CLI API', + 'KODI Kumulos Mailgun Matrix Mattermost MessageBird MSG91 Nexmo ' + 'Notifico Prowl PushBullet Pushjet Pushed Pushover Rocket.Chat Ryver ' + 'SendGrid SimplePush Slack Stride Syslog Techulus Push Telegram ' + 'Twilio Twist Twitter XBMC Microsoft MSTeams Windows Webex CLI API', author='Chris Caron', author_email='lead2gold@gmail.com', packages=find_packages(), diff --git a/test/test_rest_plugins.py b/test/test_rest_plugins.py index 617d18f5..04e0cc04 100644 --- a/test/test_rest_plugins.py +++ b/test/test_rest_plugins.py @@ -1401,6 +1401,86 @@ TEST_URLS = ( 'test_requests_exceptions': True, }), + ################################## + # NotifyNotifico + ################################## + ('notifico://', { + 'instance': None, + }), + ('notifico://:@/', { + 'instance': None, + }), + ('notifico://1234', { + # Just a project id provided (no message token) + 'instance': TypeError, + }), + ('notifico://abcd/ckhrjW8w672m6HG', { + # an invalid project id provided + 'instance': TypeError, + }), + ('notifico://1234/ckhrjW8w672m6HG', { + # A project id and message hook provided + 'instance': plugins.NotifyNotifico, + }), + ('notifico://1234/ckhrjW8w672m6HG?prefix=no', { + # Disable our prefix + 'instance': plugins.NotifyNotifico, + }), + ('notifico://1234/ckhrjW8w672m6HG?color=yes', { + 'instance': plugins.NotifyNotifico, + 'notify_type': 'info', + }), + ('notifico://1234/ckhrjW8w672m6HG?color=yes', { + 'instance': plugins.NotifyNotifico, + 'notify_type': 'success', + }), + ('notifico://1234/ckhrjW8w672m6HG?color=yes', { + 'instance': plugins.NotifyNotifico, + 'notify_type': 'warning', + }), + ('notifico://1234/ckhrjW8w672m6HG?color=yes', { + 'instance': plugins.NotifyNotifico, + 'notify_type': 'failure', + }), + ('notifico://1234/ckhrjW8w672m6HG?color=yes', { + 'instance': plugins.NotifyNotifico, + 'notify_type': 'invalid', + }), + ('notifico://1234/ckhrjW8w672m6HG?color=no', { + # Test our color flag by having it set to off + 'instance': plugins.NotifyNotifico, + + # Our expected url(privacy=True) startswith() response: + 'privacy_url': 'notifico://1...4/c...G', + }), + # Support Native URLs + ('https://n.tkte.ch/h/2144/uJmKaBW9WFk42miB146ci3Kj', { + 'instance': plugins.NotifyNotifico, + }), + ('notifico://1234/ckhrjW8w672m6HG', { + 'instance': plugins.NotifyNotifico, + # don't include an image by default + 'include_image': False, + }), + ('notifico://1234/ckhrjW8w672m6HG', { + 'instance': plugins.NotifyNotifico, + # force a failure + 'response': False, + 'requests_response_code': requests.codes.internal_server_error, + }), + ('notifico://1234/ckhrjW8w672m6HG', { + 'instance': plugins.NotifyNotifico, + # throw a bizzare code forcing us to fail to look it up + 'response': False, + 'requests_response_code': 999, + }), + ('notifico://1234/ckhrjW8w672m6HG', { + 'instance': plugins.NotifyNotifico, + # Throws a series of connection and transfer exceptions when this flag + # is set and tests that we gracfully handle them + 'test_requests_exceptions': True, + }), + ################################## # NotifyProwl ##################################