From ae0c412b4132b37422275fa6b46239213abea1f4 Mon Sep 17 00:00:00 2001 From: Chris Caron Date: Sat, 7 Oct 2023 17:40:41 -0400 Subject: [PATCH] Notifiarr Support (#953) --- KEYWORDS | 1 + README.md | 1 + apprise/config/ConfigBase.py | 2 +- apprise/plugins/NotifyNotifiarr.py | 472 +++++++++++++++++++++++++++ apprise/utils.py | 6 +- packaging/redhat/python-apprise.spec | 14 +- test/helpers/rest.py | 8 +- test/test_plugin_notifiarr.py | 168 ++++++++++ 8 files changed, 659 insertions(+), 13 deletions(-) create mode 100644 apprise/plugins/NotifyNotifiarr.py create mode 100644 test/test_plugin_notifiarr.py diff --git a/KEYWORDS b/KEYWORDS index 20fe49e0..26331e5c 100644 --- a/KEYWORDS +++ b/KEYWORDS @@ -47,6 +47,7 @@ MSTeams Nextcloud NextcloudTalk Notica +Notifiarr Notifico Ntfy Office365 diff --git a/README.md b/README.md index 2a5ca2aa..5764a7da 100644 --- a/README.md +++ b/README.md @@ -87,6 +87,7 @@ The table below identifies the services this tool supports and some example serv | [Nextcloud](https://github.com/caronc/apprise/wiki/Notify_nextcloud) | ncloud:// or nclouds:// | (TCP) 80 or 443 | ncloud://adminuser:pass@host/User
nclouds://adminuser:pass@host/User1/User2/UserN | [NextcloudTalk](https://github.com/caronc/apprise/wiki/Notify_nextcloudtalk) | nctalk:// or nctalks:// | (TCP) 80 or 443 | nctalk://user:pass@host/RoomId
nctalks://user:pass@host/RoomId1/RoomId2/RoomIdN | [Notica](https://github.com/caronc/apprise/wiki/Notify_notica) | notica:// | (TCP) 443 | notica://Token/ +| [Notifiarr](https://github.com/caronc/apprise/wiki/Notify_notifiarr) | notifiarr:// | (TCP) 443 | notifiarr://apikey/#channel
notifiarr://apikey/#channel1/#channel2/#channeln | [Notifico](https://github.com/caronc/apprise/wiki/Notify_notifico) | notifico:// | (TCP) 443 | notifico://ProjectID/MessageHook/ | [ntfy](https://github.com/caronc/apprise/wiki/Notify_ntfy) | ntfy:// | (TCP) 80 or 443 | ntfy://topic/
ntfys://topic/ | [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 diff --git a/apprise/config/ConfigBase.py b/apprise/config/ConfigBase.py index 447326c4..71c03ed1 100644 --- a/apprise/config/ConfigBase.py +++ b/apprise/config/ConfigBase.py @@ -636,7 +636,7 @@ class ConfigBase(URLBase): valid_line_re = re.compile( r'^\s*(?P([;#]+(?P.*))|' r'(\s*(?P[a-z0-9, \t_-]+)\s*=|=)?\s*' - r'((?P[a-z0-9]{2,9}://.*)|(?P[a-z0-9, \t_-]+))|' + r'((?P[a-z0-9]{1,12}://.*)|(?P[a-z0-9, \t_-]+))|' r'include\s+(?P.+))?\s*$', re.I) try: diff --git a/apprise/plugins/NotifyNotifiarr.py b/apprise/plugins/NotifyNotifiarr.py new file mode 100644 index 00000000..5e9bb950 --- /dev/null +++ b/apprise/plugins/NotifyNotifiarr.py @@ -0,0 +1,472 @@ +# -*- 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 re +import requests +from json import dumps +from itertools import chain + +from .NotifyBase import NotifyBase +from ..common import NotifyType +from ..AppriseLocale import gettext_lazy as _ +from ..common import NotifyImageSize +from ..utils import parse_list, parse_bool +from ..utils import validate_regex + +# Used to break path apart into list of channels +CHANNEL_LIST_DELIM = re.compile(r'[ \t\r\n,#\\/]+') + +CHANNEL_REGEX = re.compile( + r'^\s*(\#|\%35)?(?P[0-9]+)', re.I) + +# For API Details see: +# https://notifiarr.wiki/Client/Installation + +# Another good example: +# https://notifiarr.wiki/en/Website/ \ +# Integrations/Passthrough#payload-example-1 + + +class NotifyNotifiarr(NotifyBase): + """ + A wrapper for Notifiarr Notifications + """ + + # The default descriptive name associated with the Notification + service_name = 'Notifiarr' + + # The services URL + service_url = 'https://notifiarr.com/' + + # The default secure protocol + secure_protocol = 'notifiarr' + + # A URL that takes you to the setup/help of the specific protocol + setup_url = 'https://github.com/caronc/apprise/wiki/Notify_notifiarr' + + # The Notification URL + notify_url = 'https://notifiarr.com/api/v1/notification/apprise' + + # Notifiarr Throttling (knowing in advance reduces 429 responses) + # define('NOTIFICATION_LIMIT_SECOND_USER', 5); + # define('NOTIFICATION_LIMIT_SECOND_PATRON', 15); + + # Throttle requests ever so slightly + request_rate_per_sec = 0.04 + + # Allows the user to specify the NotifyImageSize object + image_size = NotifyImageSize.XY_256 + + # Define object templates + templates = ( + '{schema}://{apikey}/{targets}', + ) + + # Define our apikeys; these are the minimum apikeys 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, **{ + 'apikey': { + 'name': _('Token'), + 'type': 'string', + 'required': True, + 'private': True, + }, + 'target_channel': { + 'name': _('Target Channel'), + 'type': 'string', + 'prefix': '#', + 'map_to': 'targets', + }, + 'targets': { + 'name': _('Targets'), + 'type': 'list:string', + 'required': True, + }, + }) + + # Define our template arguments + template_args = dict(NotifyBase.template_args, **{ + 'key': { + 'alias_of': 'apikey', + }, + 'apikey': { + 'alias_of': 'apikey', + }, + 'discord_user': { + 'name': _('Ping Discord User'), + 'type': 'int', + }, + 'discord_role': { + 'name': _('Ping Discord Role'), + 'type': 'int', + }, + 'event': { + 'name': _('Discord Event ID'), + 'type': 'int', + }, + 'image': { + 'name': _('Include Image'), + 'type': 'bool', + 'default': False, + 'map_to': 'include_image', + }, + 'source': { + 'name': _('Source'), + 'type': 'string', + }, + 'from': { + 'alias_of': 'source' + }, + 'to': { + 'alias_of': 'targets', + }, + }) + + def __init__(self, apikey=None, include_image=None, + discord_user=None, discord_role=None, event=None, + targets=None, source=None, **kwargs): + """ + Initialize Notifiarr 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().__init__(**kwargs) + + self.apikey = apikey + if not self.apikey: + msg = 'An invalid Notifiarr APIKey ' \ + '({}) was specified.'.format(apikey) + self.logger.warning(msg) + raise TypeError(msg) + + # Place a thumbnail image inline with the message body + self.include_image = include_image \ + if isinstance(include_image, bool) \ + else self.template_args['image']['default'] + + # Set up our user if specified + self.discord_user = 0 + if discord_user: + try: + self.discord_user = int(discord_user) + + except (ValueError, TypeError): + msg = 'An invalid Notifiarr User ID ' \ + '({}) was specified.'.format(discord_user) + self.logger.warning(msg) + raise TypeError(msg) + + # Set up our role if specified + self.discord_role = 0 + if discord_role: + try: + self.discord_role = int(discord_role) + + except (ValueError, TypeError): + msg = 'An invalid Notifiarr Role ID ' \ + '({}) was specified.'.format(discord_role) + self.logger.warning(msg) + raise TypeError(msg) + + # Prepare our source (if set) + self.source = validate_regex(source) + + self.event = 0 + if event: + try: + self.event = int(event) + + except (ValueError, TypeError): + msg = 'An invalid Notifiarr Discord Event ID ' \ + '({}) was specified.'.format(event) + self.logger.warning(msg) + raise TypeError(msg) + + # Prepare our targets + self.targets = { + 'channels': [], + 'invalid': [], + } + + for target in parse_list(targets): + result = CHANNEL_REGEX.match(target) + if result: + # Store role information + self.targets['channels'].append(int(result.group('channel'))) + continue + + self.logger.warning( + 'Dropped invalid channel ' + '({}) specified.'.format(target), + ) + self.targets['invalid'].append(target) + + return + + def url(self, privacy=False, *args, **kwargs): + """ + Returns the URL built dynamically based on specified arguments. + """ + + # Define any URL parameters + params = { + 'image': 'yes' if self.include_image else 'no' + } + + if self.source: + params['source'] = self.source + + if self.discord_user: + params['discord_user'] = self.discord_user + + if self.discord_role: + params['discord_role'] = self.discord_role + + if self.event: + params['event'] = self.event + + # Extend our parameters + params.update(self.url_parameters(privacy=privacy, *args, **kwargs)) + + return '{schema}://{apikey}' \ + '/{targets}?{params}'.format( + schema=self.secure_protocol, + apikey=self.pprint(self.apikey, privacy, safe=''), + targets='/'.join( + [NotifyNotifiarr.quote(x, safe='+#@') for x in chain( + # Channels + ['#{}'.format(x) for x in self.targets['channels']], + # Pass along the same invalid entries as were provided + self.targets['invalid'], + )]), + params=NotifyNotifiarr.urlencode(params), + ) + + def send(self, body, title='', notify_type=NotifyType.INFO, **kwargs): + """ + Perform Notifiarr Notification + """ + + if not self.targets['channels']: + # There were no services to notify + self.logger.warning( + 'There were no Notifiarr channels to notify.') + return False + + # No error to start with + has_error = False + + # Acquire image_url + image_url = self.image_url(notify_type) + + for idx, channel in enumerate(self.targets['channels']): + # prepare Notifiarr Object + payload = { + 'source': self.source if self.source else self.app_id, + 'type': notify_type, + 'notification': { + 'update': True if self.event else False, + 'name': self.app_id, + 'event': str(self.event) + if self.event else "", + }, + 'discord': { + 'color': self.color(notify_type), + 'ping': { + 'pingUser': self.discord_user + if not idx and self.discord_user else 0, + 'pingRole': self.discord_role + if not idx and self.discord_role else 0, + }, + 'text': { + 'title': title, + 'content': '', + 'description': body, + }, + 'ids': { + 'channel': channel, + } + } + } + + if self.include_image and image_url: + payload['discord']['text']['icon'] = image_url + + if not self._send(payload): + has_error = True + + return not has_error + + def _send(self, payload): + """ + Send notification + """ + self.logger.debug('Notifiarr POST URL: %s (cert_verify=%r)' % ( + self.notify_url, self.verify_certificate, + )) + self.logger.debug('Notifiarr Payload: %s' % str(payload)) + + # Prepare HTTP Headers + headers = { + 'User-Agent': self.app_id, + 'Content-Type': 'application/json', + 'Accept': 'text/plain', + 'X-api-Key': self.apikey, + } + + # Always call throttle before any remote server i/o is made + self.throttle() + + try: + r = requests.post( + self.notify_url, + data=dumps(payload), + headers=headers, + verify=self.verify_certificate, + timeout=self.request_timeout, + ) + if r.status_code < 200 or r.status_code >= 300: + # We had a problem + status_str = \ + NotifyNotifiarr.http_response_code_lookup(r.status_code) + + self.logger.warning( + 'Failed to send Notifiarr %s notification: ' + '%serror=%s.', + status_str, + ', ' if status_str else '', + str(r.status_code)) + + self.logger.debug('Response Details:\r\n{}'.format(r.content)) + + # Return; we're done + return False + + else: + self.logger.info('Sent Notifiarr notification.') + + except requests.RequestException as e: + self.logger.warning( + 'A Connection error occurred sending Notifiarr ' + 'Chat notification to %s.' % self.host) + self.logger.debug('Socket Exception: %s' % str(e)) + + # Return; we're done + return False + + return True + + def __len__(self): + """ + Returns the number of targets associated with this notification + """ + targets = len(self.targets['channels']) + len(self.targets['invalid']) + return targets if targets > 0 else 1 + + @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 + + # Get channels + results['targets'] = NotifyNotifiarr.split_path(results['fullpath']) + + if 'discord_user' in results['qsd'] and \ + len(results['qsd']['discord_user']): + results['discord_user'] = \ + NotifyNotifiarr.unquote( + results['qsd']['discord_user']) + + if 'discord_role' in results['qsd'] and \ + len(results['qsd']['discord_role']): + results['discord_role'] = \ + NotifyNotifiarr.unquote(results['qsd']['discord_role']) + + if 'event' in results['qsd'] and \ + len(results['qsd']['event']): + results['event'] = \ + NotifyNotifiarr.unquote(results['qsd']['event']) + + # Include images with our message + results['include_image'] = \ + parse_bool(results['qsd'].get('image', False)) + + # Track if we need to extract the hostname as a target + host_is_potential_target = False + + if 'source' in results['qsd'] and len(results['qsd']['source']): + results['source'] = \ + NotifyNotifiarr.unquote(results['qsd']['source']) + + elif 'from' in results['qsd'] and len(results['qsd']['from']): + results['source'] = \ + NotifyNotifiarr.unquote(results['qsd']['from']) + + # Set our apikey if found as an argument + if 'apikey' in results['qsd'] and len(results['qsd']['apikey']): + results['apikey'] = \ + NotifyNotifiarr.unquote(results['qsd']['apikey']) + + host_is_potential_target = True + + elif 'key' in results['qsd'] and len(results['qsd']['key']): + results['apikey'] = \ + NotifyNotifiarr.unquote(results['qsd']['key']) + + host_is_potential_target = True + + else: + # Pop the first element (this is the api key) + results['apikey'] = \ + NotifyNotifiarr.unquote(results['host']) + + if host_is_potential_target is True and results['host']: + results['targets'].append(NotifyNotifiarr.unquote(results['host'])) + + # Support the 'to' variable so that we can support rooms this way too + # The 'to' makes it easier to use yaml configuration + if 'to' in results['qsd'] and len(results['qsd']['to']): + results['targets'] += [x for x in filter( + bool, CHANNEL_LIST_DELIM.split( + NotifyNotifiarr.unquote(results['qsd']['to'])))] + + return results diff --git a/apprise/utils.py b/apprise/utils.py index 77be9791..97115625 100644 --- a/apprise/utils.py +++ b/apprise/utils.py @@ -143,14 +143,14 @@ NOTIFY_CUSTOM_DEL_TOKENS = re.compile(r'^-(?P.*)\s*') NOTIFY_CUSTOM_COLON_TOKENS = re.compile(r'^:(?P.*)\s*') # Used for attempting to acquire the schema if the URL can't be parsed. -GET_SCHEMA_RE = re.compile(r'\s*(?P[a-z0-9]{2,9})://.*$', re.I) +GET_SCHEMA_RE = re.compile(r'\s*(?P[a-z0-9]{1,12})://.*$', re.I) # Used for validating that a provided entry is indeed a schema # this is slightly different then the GET_SCHEMA_RE above which # insists the schema is only valid with a :// entry. this one # extrapolates the individual entries URL_DETAILS_RE = re.compile( - r'\s*(?P[a-z0-9]{2,9})(://(?P.*))?$', re.I) + r'\s*(?P[a-z0-9]{1,12})(://(?P.*))?$', re.I) # Regular expression based and expanded from: # http://www.regular-expressions.info/email.html @@ -194,7 +194,7 @@ CALL_SIGN_DETECTION_RE = re.compile( # Regular expression used to destinguish between multiple URLs URL_DETECTION_RE = re.compile( - r'([a-z0-9]+?:\/\/.*?)(?=$|[\s,]+[a-z0-9]{2,9}?:\/\/)', re.I) + r'([a-z0-9]+?:\/\/.*?)(?=$|[\s,]+[a-z0-9]{1,12}?:\/\/)', re.I) EMAIL_DETECTION_RE = re.compile( r'[\s,]*([^@]+@.*?)(?=$|[\s,]+' diff --git a/packaging/redhat/python-apprise.spec b/packaging/redhat/python-apprise.spec index 98122813..fb564060 100644 --- a/packaging/redhat/python-apprise.spec +++ b/packaging/redhat/python-apprise.spec @@ -48,13 +48,13 @@ DAPNET, DingTalk, Discord, E-Mail, Emby, Faast, FCM, Flock, Google Chat, Gotify, Growl, Guilded, Home Assistant, IFTTT, Join, Kavenegar, KODI, Kumulos, LaMetric, Line, MacOSX, Mailgun, Mastodon, Mattermost, Matrix, MessageBird, Microsoft Windows, Microsoft Teams, Misskey, MQTT, MSG91, MyAndroid, Nexmo, -Nextcloud, NextcloudTalk, Notica, Notifico, ntfy, Office365, OneSignal, -Opsgenie, PagerDuty, PagerTree, ParsePlatform, PopcornNotify, Prowl, Pushalot, -PushBullet, Pushjet, PushMe, Pushover, PushSafer, Pushy, PushDeer, Reddit, -Rocket.Chat, RSyslog, SendGrid, ServerChan, Signal, SimplePush, Sinch, Slack, -SMSEagle, SMTP2Go, Spontit, SparkPost, Super Toasty, Streamlabs, Stride, -Syslog, Techulus Push, Telegram, Twilio, Twitter, Twist, XBMC, Voipms, Vonage, -WhatsApp, Webex Teams} +Nextcloud, NextcloudTalk, Notica, Notifiarr, Notifico, ntfy, Office365, +OneSignal, Opsgenie, PagerDuty, PagerTree, ParsePlatform, PopcornNotify, +Prowl, Pushalot, PushBullet, Pushjet, PushMe, Pushover, PushSafer, Pushy, +PushDeer, Reddit, Rocket.Chat, RSyslog, SendGrid, ServerChan, Signal, +SimplePush, Sinch, Slack, SMSEagle, SMTP2Go, Spontit, SparkPost, Super Toasty, +Streamlabs, Stride, Syslog, Techulus Push, Telegram, Twilio, Twitter, Twist, +XBMC, Voipms, Vonage, WhatsApp, Webex Teams} Name: python-%{pypi_name} Version: 1.5.0 diff --git a/test/helpers/rest.py b/test/helpers/rest.py index 9dbe2c8e..e77d484d 100644 --- a/test/helpers/rest.py +++ b/test/helpers/rest.py @@ -391,9 +391,13 @@ class AppriseURLTester: targets = len(obj) # check that we're as expected - assert obj.notify( + _resp = obj.notify( body=self.body, title=self.title, - notify_type=notify_type) == notify_response + notify_type=notify_type) + if _resp != notify_response: + print('%s notify() returned %s (but expected %s)' % ( + url, _resp, notify_response)) + assert False if notify_response: # If we successfully got a response, there must have been diff --git a/test/test_plugin_notifiarr.py b/test/test_plugin_notifiarr.py new file mode 100644 index 00000000..ce23b399 --- /dev/null +++ b/test/test_plugin_notifiarr.py @@ -0,0 +1,168 @@ +# -*- 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 apprise.plugins.NotifyNotifiarr import NotifyNotifiarr +from helpers import AppriseURLTester + +# Disable logging for a cleaner testing output +import logging +logging.disable(logging.CRITICAL) + +# Our Testing URLs +apprise_url_tests = ( + ('notifiarr://:@/', { + 'instance': TypeError, + }), + ('notifiarr://', { + 'instance': TypeError, + }), + ('notifiarr://apikey', { + 'instance': NotifyNotifiarr, + + # Response will fail due to no targets defined + 'notify_response': False, + + # Our expected url(privacy=True) startswith() response: + 'privacy_url': 'notifiarr://a...y', + }), + ('notifiarr://apikey/1234/?discord_user=invalid', { + 'instance': TypeError, + }), + ('notifiarr://apikey/1234/?discord_role=invalid', { + 'instance': TypeError, + }), + ('notifiarr://apikey/1234/?event=invalid', { + 'instance': TypeError, + }), + ('notifiarr://apikey/%%invalid%%', { + 'instance': NotifyNotifiarr, + + # Response will fail due to no targets defined + 'notify_response': False, + + # Our expected url(privacy=True) startswith() response: + 'privacy_url': 'notifiarr://a...y', + }), + ('notifiarr://apikey/#123', { + 'instance': NotifyNotifiarr, + + # Our expected url(privacy=True) startswith() response: + 'privacy_url': 'notifiarr://a...y/#123' + }), + ('notifiarr://apikey/123?image=No', { + 'instance': NotifyNotifiarr, + }), + ('notifiarr://apikey/123?image=yes', { + 'instance': NotifyNotifiarr, + # Our expected url(privacy=True) startswith() response: + 'privacy_url': 'notifiarr://a...y/#123', + }), + ('notifiarr://apikey/?to=123,432', { + 'instance': NotifyNotifiarr, + # Our expected url(privacy=True) startswith() response: + 'privacy_url': 'notifiarr://a...y/#123/#432', + }), + ('notifiarr://123/?apikey=myapikey', { + 'instance': NotifyNotifiarr, + # Our expected url(privacy=True) startswith() response: + 'privacy_url': 'notifiarr://m...y/#123', + }), + ('notifiarr://123/?apikey=myapikey&source=My%20System', { + 'instance': NotifyNotifiarr, + # Our expected url(privacy=True) startswith() response: + 'privacy_url': 'notifiarr://m...y/#123', + }), + ('notifiarr://123/?apikey=myapikey&from=My%20System', { + 'instance': NotifyNotifiarr, + # Our expected url(privacy=True) startswith() response: + 'privacy_url': 'notifiarr://m...y/#123', + }), + ('notifiarr://?apikey=myapikey', { + # No Channel or host + 'instance': NotifyNotifiarr, + # Response will fail due to no targets defined + 'notify_response': False, + # Our expected url(privacy=True) startswith() response: + 'privacy_url': 'notifiarr://m...y/', + }), + ('notifiarr://invalid?apikey=myapikey', { + # No Channel or host + 'instance': NotifyNotifiarr, + # invalid channel + 'notify_response': False, + # Our expected url(privacy=True) startswith() response: + 'privacy_url': 'notifiarr://m...y/', + }), + ('notifiarr://123/325/?apikey=myapikey', { + 'instance': NotifyNotifiarr, + # Our expected url(privacy=True) startswith() response: + 'privacy_url': 'notifiarr://m...y/#123/#325', + }), + ('notifiarr://12/?key=myapikey&discord_user=23' + '&discord_role=12&event=123', { + 'instance': NotifyNotifiarr, + # Our expected url(privacy=True) startswith() response: + 'privacy_url': 'notifiarr://m...y/#12'}), + ('notifiarr://apikey/123/', { + 'instance': NotifyNotifiarr, + }), + ('notifiarr://apikey/123', { + 'instance': NotifyNotifiarr, + # force a failure + 'response': False, + 'requests_response_code': requests.codes.internal_server_error, + }), + ('notifiarr://apikey/123', { + 'instance': NotifyNotifiarr, + # throw a bizzare code forcing us to fail to look it up + 'response': False, + 'requests_response_code': 999, + }), + ('notifiarr://apikey/123', { + 'instance': NotifyNotifiarr, + # 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_custom_notifiarr_urls(): + """ + NotifyNotifiarr() Apprise URLs + + """ + + # Run our general tests + AppriseURLTester(tests=apprise_url_tests).run_all()