From f36bb0b179b0c8db2fe56c8d3fe47b28de4b60f7 Mon Sep 17 00:00:00 2001 From: JackDandy Date: Sat, 21 Oct 2023 12:47:10 +0100 Subject: [PATCH] =?UTF-8?q?Update=20Apprise=201.3.0=20(6458ab0)=20?= =?UTF-8?q?=E2=86=92=201.6.0=20(0c0d5da).?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGES.md | 1 + lib/apprise/Apprise.py | 245 ++++---- lib/apprise/AppriseAsset.py | 6 +- lib/apprise/AppriseAttachment.py | 6 +- lib/apprise/AppriseConfig.py | 6 +- lib/apprise/AppriseLocale.py | 367 +++++++----- lib/apprise/URLBase.py | 109 +++- lib/apprise/__init__.py | 8 +- lib/apprise/attachment/AttachBase.py | 9 +- lib/apprise/attachment/AttachFile.py | 6 +- lib/apprise/attachment/AttachHTTP.py | 6 +- lib/apprise/attachment/__init__.py | 6 +- lib/apprise/common.py | 6 +- lib/apprise/config/ConfigBase.py | 164 +++++- lib/apprise/config/ConfigFile.py | 6 +- lib/apprise/config/ConfigHTTP.py | 6 +- lib/apprise/config/ConfigMemory.py | 6 +- lib/apprise/config/__init__.py | 6 +- lib/apprise/conversion.py | 6 +- lib/apprise/decorators/CustomNotifyPlugin.py | 7 +- lib/apprise/decorators/__init__.py | 6 +- lib/apprise/decorators/notify.py | 6 +- lib/apprise/i18n/apprise.pot | 80 ++- lib/apprise/i18n/en/LC_MESSAGES/apprise.po | 369 ++++++------ lib/apprise/logger.py | 6 +- lib/apprise/plugins/NotifyAppriseAPI.py | 145 ++++- lib/apprise/plugins/NotifyBark.py | 17 +- lib/apprise/plugins/NotifyBase.py | 42 +- lib/apprise/plugins/NotifyBoxcar.py | 72 ++- lib/apprise/plugins/NotifyBulkSMS.py | 27 +- lib/apprise/plugins/NotifyBurstSMS.py | 460 +++++++++++++++ lib/apprise/plugins/NotifyClickSend.py | 21 +- lib/apprise/plugins/NotifyD7Networks.py | 16 +- lib/apprise/plugins/NotifyDBus.py | 6 +- lib/apprise/plugins/NotifyDapnet.py | 21 +- lib/apprise/plugins/NotifyDingTalk.py | 22 +- lib/apprise/plugins/NotifyDiscord.py | 262 ++++++--- lib/apprise/plugins/NotifyEmail.py | 37 +- lib/apprise/plugins/NotifyEmby.py | 6 +- lib/apprise/plugins/NotifyEnigma2.py | 6 +- lib/apprise/plugins/NotifyFCM/__init__.py | 14 +- lib/apprise/plugins/NotifyFCM/color.py | 6 +- lib/apprise/plugins/NotifyFCM/common.py | 6 +- lib/apprise/plugins/NotifyFCM/oauth.py | 17 +- lib/apprise/plugins/NotifyFCM/priority.py | 6 +- lib/apprise/plugins/NotifyFaast.py | 6 +- lib/apprise/plugins/NotifyFlock.py | 20 +- lib/apprise/plugins/NotifyForm.py | 72 ++- lib/apprise/plugins/NotifyGitter.py | 419 -------------- lib/apprise/plugins/NotifyGnome.py | 6 +- lib/apprise/plugins/NotifyGoogleChat.py | 6 +- lib/apprise/plugins/NotifyGotify.py | 7 +- lib/apprise/plugins/NotifyGrowl.py | 6 +- lib/apprise/plugins/NotifyGuilded.py | 6 +- lib/apprise/plugins/NotifyHomeAssistant.py | 6 +- lib/apprise/plugins/NotifyIFTTT.py | 13 +- lib/apprise/plugins/NotifyJSON.py | 60 +- lib/apprise/plugins/NotifyJoin.py | 13 +- lib/apprise/plugins/NotifyKavenegar.py | 12 +- lib/apprise/plugins/NotifyKumulos.py | 6 +- lib/apprise/plugins/NotifyLametric.py | 8 +- lib/apprise/plugins/NotifyLine.py | 13 +- lib/apprise/plugins/NotifyMQTT.py | 16 +- lib/apprise/plugins/NotifyMSG91.py | 230 ++++---- lib/apprise/plugins/NotifyMSTeams.py | 6 +- lib/apprise/plugins/NotifyMacOSX.py | 9 +- lib/apprise/plugins/NotifyMailgun.py | 32 +- lib/apprise/plugins/NotifyMastodon.py | 32 +- lib/apprise/plugins/NotifyMatrix.py | 263 +++++++-- lib/apprise/plugins/NotifyMatterMost.py | 12 +- lib/apprise/plugins/NotifyMessageBird.py | 13 +- lib/apprise/plugins/NotifyMisskey.py | 7 +- lib/apprise/plugins/NotifyNextcloud.py | 50 +- lib/apprise/plugins/NotifyNextcloudTalk.py | 66 ++- lib/apprise/plugins/NotifyNotica.py | 18 +- lib/apprise/plugins/NotifyNotifiarr.py | 472 ++++++++++++++++ lib/apprise/plugins/NotifyNotifico.py | 6 +- lib/apprise/plugins/NotifyNtfy.py | 65 +-- lib/apprise/plugins/NotifyOffice365.py | 19 +- lib/apprise/plugins/NotifyOneSignal.py | 35 +- lib/apprise/plugins/NotifyOpsgenie.py | 24 +- lib/apprise/plugins/NotifyPagerDuty.py | 8 +- lib/apprise/plugins/NotifyPagerTree.py | 6 +- lib/apprise/plugins/NotifyParsePlatform.py | 8 +- lib/apprise/plugins/NotifyPopcornNotify.py | 22 +- lib/apprise/plugins/NotifyProwl.py | 6 +- lib/apprise/plugins/NotifyPushBullet.py | 32 +- lib/apprise/plugins/NotifyPushDeer.py | 218 ++++++++ lib/apprise/plugins/NotifyPushMe.py | 221 ++++++++ lib/apprise/plugins/NotifyPushSafer.py | 17 +- lib/apprise/plugins/NotifyPushed.py | 13 +- lib/apprise/plugins/NotifyPushjet.py | 6 +- lib/apprise/plugins/NotifyPushover.py | 154 ++--- lib/apprise/plugins/NotifyPushy.py | 384 +++++++++++++ lib/apprise/plugins/NotifyRSyslog.py | 376 +++++++++++++ lib/apprise/plugins/NotifyReddit.py | 43 +- lib/apprise/plugins/NotifyRocketChat.py | 13 +- lib/apprise/plugins/NotifyRyver.py | 11 +- lib/apprise/plugins/NotifySES.py | 29 +- lib/apprise/plugins/NotifySMSEagle.py | 59 +- lib/apprise/plugins/NotifySMTP2Go.py | 28 +- lib/apprise/plugins/NotifySNS.py | 19 +- lib/apprise/plugins/NotifySendGrid.py | 13 +- lib/apprise/plugins/NotifyServerChan.py | 8 +- lib/apprise/plugins/NotifySignalAPI.py | 28 +- lib/apprise/plugins/NotifySimplePush.py | 10 +- lib/apprise/plugins/NotifySinch.py | 13 +- lib/apprise/plugins/NotifySlack.py | 53 +- lib/apprise/plugins/NotifySparkPost.py | 31 +- lib/apprise/plugins/NotifySpontit.py | 14 +- lib/apprise/plugins/NotifyStreamlabs.py | 9 +- lib/apprise/plugins/NotifySyslog.py | 193 +------ lib/apprise/plugins/NotifyTechulusPush.py | 6 +- lib/apprise/plugins/NotifyTelegram.py | 145 ++++- lib/apprise/plugins/NotifyTwilio.py | 13 +- lib/apprise/plugins/NotifyTwist.py | 14 +- lib/apprise/plugins/NotifyTwitter.py | 69 ++- lib/apprise/plugins/NotifyVoipms.py | 15 +- lib/apprise/plugins/NotifyVonage.py | 13 +- lib/apprise/plugins/NotifyWebexTeams.py | 6 +- lib/apprise/plugins/NotifyWhatsApp.py | 559 +++++++++++++++++++ lib/apprise/plugins/NotifyWindows.py | 8 +- lib/apprise/plugins/NotifyXBMC.py | 6 +- lib/apprise/plugins/NotifyXML.py | 83 ++- lib/apprise/plugins/NotifyZulip.py | 13 +- lib/apprise/plugins/__init__.py | 37 +- lib/apprise/py3compat/__init__.py | 0 lib/apprise/py3compat/asyncio.py | 140 ----- lib/apprise/utils.py | 44 +- 129 files changed, 5607 insertions(+), 2321 deletions(-) create mode 100644 lib/apprise/plugins/NotifyBurstSMS.py delete mode 100644 lib/apprise/plugins/NotifyGitter.py create mode 100644 lib/apprise/plugins/NotifyNotifiarr.py create mode 100644 lib/apprise/plugins/NotifyPushDeer.py create mode 100644 lib/apprise/plugins/NotifyPushMe.py create mode 100644 lib/apprise/plugins/NotifyPushy.py create mode 100644 lib/apprise/plugins/NotifyRSyslog.py create mode 100644 lib/apprise/plugins/NotifyWhatsApp.py delete mode 100644 lib/apprise/py3compat/__init__.py delete mode 100644 lib/apprise/py3compat/asyncio.py diff --git a/CHANGES.md b/CHANGES.md index 883451b4..98cf2059 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,5 +1,6 @@ ### 3.31.0 (2023-1x-xx xx:xx:00 UTC) +* Update Apprise 1.3.0 (6458ab0) to 1.6.0 (0c0d5da) * Update attr 22.2.0 (683d056) to 23.1.0 (67e4ff2) * Update Beautiful Soup 4.12.2 to 4.12.2 (30c58a1) * Update diskcache 5.6.1 (4d30686) to 5.6.3 (323787f) diff --git a/lib/apprise/Apprise.py b/lib/apprise/Apprise.py index 19dde830..4c83c481 100644 --- a/lib/apprise/Apprise.py +++ b/lib/apprise/Apprise.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -# BSD 3-Clause License +# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2023, Chris Caron @@ -14,10 +14,6 @@ # 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 @@ -31,8 +27,8 @@ # POSSIBILITY OF SUCH DAMAGE. import asyncio +import concurrent.futures as cf import os -from functools import partial from itertools import chain from . import common from .conversion import convert_between @@ -376,7 +372,7 @@ class Apprise: try: # Process arguments and build synchronous and asynchronous calls # (this step can throw internal errors). - sync_partials, async_cors = self._create_notify_calls( + sequential_calls, parallel_calls = self._create_notify_calls( body, title, notify_type=notify_type, body_format=body_format, tag=tag, match_always=match_always, attach=attach, @@ -387,49 +383,13 @@ class Apprise: # No notifications sent, and there was an internal error. return False - if not sync_partials and not async_cors: + if not sequential_calls and not parallel_calls: # Nothing to send return None - sync_result = Apprise._notify_all(*sync_partials) - - if async_cors: - # A single coroutine sends all asynchronous notifications in - # parallel. - all_cor = Apprise._async_notify_all(*async_cors) - - try: - # Python <3.7 automatically starts an event loop if there isn't - # already one for the main thread. - loop = asyncio.get_event_loop() - - except RuntimeError: - # Python >=3.7 raises this exception if there isn't already an - # event loop. So, we can spin up our own. - loop = asyncio.new_event_loop() - asyncio.set_event_loop(loop) - loop.set_debug(self.debug) - - # Run the coroutine and wait for the result. - async_result = loop.run_until_complete(all_cor) - - # Clean up the loop. - loop.close() - asyncio.set_event_loop(None) - - else: - old_debug = loop.get_debug() - loop.set_debug(self.debug) - - # Run the coroutine and wait for the result. - async_result = loop.run_until_complete(all_cor) - - loop.set_debug(old_debug) - - else: - async_result = True - - return sync_result and async_result + sequential_result = Apprise._notify_sequential(*sequential_calls) + parallel_result = Apprise._notify_parallel_threadpool(*parallel_calls) + return sequential_result and parallel_result async def async_notify(self, *args, **kwargs): """ @@ -442,41 +402,42 @@ class Apprise: try: # Process arguments and build synchronous and asynchronous calls # (this step can throw internal errors). - sync_partials, async_cors = self._create_notify_calls( + sequential_calls, parallel_calls = self._create_notify_calls( *args, **kwargs) except TypeError: # No notifications sent, and there was an internal error. return False - if not sync_partials and not async_cors: + if not sequential_calls and not parallel_calls: # Nothing to send return None - sync_result = Apprise._notify_all(*sync_partials) - async_result = await Apprise._async_notify_all(*async_cors) - return sync_result and async_result + sequential_result = Apprise._notify_sequential(*sequential_calls) + parallel_result = \ + await Apprise._notify_parallel_asyncio(*parallel_calls) + return sequential_result and parallel_result def _create_notify_calls(self, *args, **kwargs): """ Creates notifications for all the plugins loaded. - Returns a list of synchronous calls (partial functions with no - arguments required) for plugins with async disabled and a list of - asynchronous calls (coroutines) for plugins with async enabled. + Returns a list of (server, notify() kwargs) tuples for plugins with + parallelism disabled and another list for plugins with parallelism + enabled. """ all_calls = list(self._create_notify_gen(*args, **kwargs)) - # Split into synchronous partials and asynchronous coroutines. - sync_partials, async_cors = [], [] - for notify in all_calls: - if asyncio.iscoroutine(notify): - async_cors.append(notify) + # Split into sequential and parallel notify() calls. + sequential, parallel = [], [] + for (server, notify_kwargs) in all_calls: + if server.asset.async_mode: + parallel.append((server, notify_kwargs)) else: - sync_partials.append(notify) + sequential.append((server, notify_kwargs)) - return sync_partials, async_cors + return sequential, parallel def _create_notify_gen(self, body, title='', notify_type=common.NotifyType.INFO, @@ -493,7 +454,7 @@ class Apprise: logger.error(msg) raise TypeError(msg) - if not (title or body): + if not (title or body or attach): msg = "No message content specified to deliver" logger.error(msg) raise TypeError(msg) @@ -533,25 +494,29 @@ class Apprise: # If our code reaches here, we either did not define a tag (it # was set to None), or we did define a tag and the logic above # determined we need to notify the service it's associated with - if server.notify_format not in conversion_body_map: - # Perform Conversion - conversion_body_map[server.notify_format] = \ - convert_between( - body_format, server.notify_format, content=body) + + # First we need to generate a key we will use to determine if we + # need to build our data out. Entries without are merged with + # the body at this stage. + key = server.notify_format if server.title_maxlen > 0\ + else f'_{server.notify_format}' + + if key not in conversion_title_map: # Prepare our title - conversion_title_map[server.notify_format] = \ - '' if not title else title + conversion_title_map[key] = '' if not title else title - # Tidy Title IF required (hence it will become part of the - # body) - if server.title_maxlen <= 0 and \ - conversion_title_map[server.notify_format]: + # Conversion of title only occurs for services where the title + # is blended with the body (title_maxlen <= 0) + if conversion_title_map[key] and server.title_maxlen <= 0: + conversion_title_map[key] = convert_between( + body_format, server.notify_format, + content=conversion_title_map[key]) - conversion_title_map[server.notify_format] = \ - convert_between( - body_format, server.notify_format, - content=conversion_title_map[server.notify_format]) + # Our body is always converted no matter what + conversion_body_map[key] = \ + convert_between( + body_format, server.notify_format, content=body) if interpret_escapes: # @@ -561,13 +526,13 @@ class Apprise: try: # Added overhead required due to Python 3 Encoding Bug # identified here: https://bugs.python.org/issue21331 - conversion_body_map[server.notify_format] = \ - conversion_body_map[server.notify_format]\ + conversion_body_map[key] = \ + conversion_body_map[key]\ .encode('ascii', 'backslashreplace')\ .decode('unicode-escape') - conversion_title_map[server.notify_format] = \ - conversion_title_map[server.notify_format]\ + conversion_title_map[key] = \ + conversion_title_map[key]\ .encode('ascii', 'backslashreplace')\ .decode('unicode-escape') @@ -578,29 +543,26 @@ class Apprise: raise TypeError(msg) kwargs = dict( - body=conversion_body_map[server.notify_format], - title=conversion_title_map[server.notify_format], + body=conversion_body_map[key], + title=conversion_title_map[key], notify_type=notify_type, attach=attach, body_format=body_format ) - if server.asset.async_mode: - yield server.async_notify(**kwargs) - else: - yield partial(server.notify, **kwargs) + yield (server, kwargs) @staticmethod - def _notify_all(*partials): + def _notify_sequential(*servers_kwargs): """ - Process a list of synchronous notify() calls. + Process a list of notify() calls sequentially and synchronously. """ success = True - for notify in partials: + for (server, kwargs) in servers_kwargs: try: # Send notification - result = notify() + result = server.notify(**kwargs) success = success and result except TypeError: @@ -616,14 +578,71 @@ class Apprise: return success @staticmethod - async def _async_notify_all(*cors): + def _notify_parallel_threadpool(*servers_kwargs): """ - Process a list of asynchronous async_notify() calls. + Process a list of notify() calls in parallel and synchronously. """ + n_calls = len(servers_kwargs) + + # 0-length case + if n_calls == 0: + return True + + # There's no need to use a thread pool for just a single notification + if n_calls == 1: + return Apprise._notify_sequential(servers_kwargs[0]) + # Create log entry - logger.info('Notifying %d service(s) asynchronously.', len(cors)) + logger.info( + 'Notifying %d service(s) with threads.', len(servers_kwargs)) + with cf.ThreadPoolExecutor() as executor: + success = True + futures = [executor.submit(server.notify, **kwargs) + for (server, kwargs) in servers_kwargs] + + for future in cf.as_completed(futures): + try: + result = future.result() + success = success and result + + except TypeError: + # These are our internally thrown notifications. + success = False + + except Exception: + # A catch all so we don't have to abort early + # just because one of our plugins has a bug in it. + logger.exception("Unhandled Notification Exception") + success = False + + return success + + @staticmethod + async def _notify_parallel_asyncio(*servers_kwargs): + """ + Process a list of async_notify() calls in parallel and asynchronously. + """ + + n_calls = len(servers_kwargs) + + # 0-length case + if n_calls == 0: + return True + + # (Unlike with the thread pool, we don't optimize for the single- + # notification case because asyncio can do useful work while waiting + # for that thread to complete) + + # Create log entry + logger.info( + 'Notifying %d service(s) asynchronously.', len(servers_kwargs)) + + async def do_call(server, kwargs): + return await server.async_notify(**kwargs) + + cors = (do_call(server, kwargs) for (server, kwargs) in servers_kwargs) results = await asyncio.gather(*cors, return_exceptions=True) if any(isinstance(status, Exception) @@ -665,6 +684,12 @@ class Apprise: 'setup_url': getattr(plugin, 'setup_url', None), # Placeholder - populated below 'details': None, + + # Let upstream service know of the plugins that support + # attachments + 'attachment_support': getattr( + plugin, 'attachment_support', False), + # Differentiat between what is a custom loaded plugin and # which is native. 'category': getattr(plugin, 'category', None) @@ -790,6 +815,36 @@ class Apprise: # If we reach here, then we indexed out of range raise IndexError('list index out of range') + def __getstate__(self): + """ + Pickle Support dumps() + """ + attributes = { + 'asset': self.asset, + # Prepare our URL list as we need to extract the associated tags + # and asset details associated with it + 'urls': [{ + 'url': server.url(privacy=False), + 'tag': server.tags if server.tags else None, + 'asset': server.asset} for server in self.servers], + 'locale': self.locale, + 'debug': self.debug, + 'location': self.location, + } + + return attributes + + def __setstate__(self, state): + """ + Pickle Support loads() + """ + self.servers = list() + self.asset = state['asset'] + self.locale = state['locale'] + self.location = state['location'] + for entry in state['urls']: + self.add(entry['url'], asset=entry['asset'], tag=entry['tag']) + def __bool__(self): """ Allows the Apprise object to be wrapped in an 'if statement'. diff --git a/lib/apprise/AppriseAsset.py b/lib/apprise/AppriseAsset.py index 34821e27..835c3b6a 100644 --- a/lib/apprise/AppriseAsset.py +++ b/lib/apprise/AppriseAsset.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -# BSD 3-Clause License +# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2023, Chris Caron @@ -14,10 +14,6 @@ # 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 diff --git a/lib/apprise/AppriseAttachment.py b/lib/apprise/AppriseAttachment.py index 0a3913ed..e00645d2 100644 --- a/lib/apprise/AppriseAttachment.py +++ b/lib/apprise/AppriseAttachment.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -# BSD 3-Clause License +# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2023, Chris Caron @@ -14,10 +14,6 @@ # 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 diff --git a/lib/apprise/AppriseConfig.py b/lib/apprise/AppriseConfig.py index 8f285777..07e7b48e 100644 --- a/lib/apprise/AppriseConfig.py +++ b/lib/apprise/AppriseConfig.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -# BSD 3-Clause License +# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2023, Chris Caron @@ -14,10 +14,6 @@ # 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 diff --git a/lib/apprise/AppriseLocale.py b/lib/apprise/AppriseLocale.py index ce61d0c9..c80afae2 100644 --- a/lib/apprise/AppriseLocale.py +++ b/lib/apprise/AppriseLocale.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -# BSD 3-Clause License +# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2023, Chris Caron @@ -14,10 +14,6 @@ # 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 @@ -33,14 +29,13 @@ import ctypes import locale import contextlib +import os +import re from os.path import join from os.path import dirname from os.path import abspath from .logger import logger -# Define our translation domain -DOMAIN = 'apprise' -LOCALE_DIR = abspath(join(dirname(__file__), 'i18n')) # This gets toggled to True if we succeed GETTEXT_LOADED = False @@ -49,17 +44,220 @@ try: # Initialize gettext import gettext - # install() creates a _() in our builtins - gettext.install(DOMAIN, localedir=LOCALE_DIR) - # Toggle our flag GETTEXT_LOADED = True except ImportError: - # gettext isn't available; no problem, just fall back to using - # the library features without multi-language support. - import builtins - builtins.__dict__['_'] = lambda x: x # pragma: no branch + # gettext isn't available; no problem; Use the library features without + # multi-language support. + pass + + +class AppriseLocale: + """ + A wrapper class to gettext so that we can manipulate multiple lanaguages + on the fly if required. + + """ + + # Define our translation domain + _domain = 'apprise' + + # The path to our translations + _locale_dir = abspath(join(dirname(__file__), 'i18n')) + + # Locale regular expression + _local_re = re.compile( + r'^((?PC)|(?P([a-z]{2}))([_:](?P[a-z]{2}))?)' + r'(\.(?P[a-z0-9-]+))?$', re.IGNORECASE) + + # Define our default encoding + _default_encoding = 'utf-8' + + # The function to assign `_` by default + _fn = 'gettext' + + # The language we should fall back to if all else fails + _default_language = 'en' + + def __init__(self, language=None): + """ + Initializes our object, if a language is specified, then we + initialize ourselves to that, otherwise we use whatever we detect + from the local operating system. If all else fails, we resort to the + defined default_language. + + """ + + # Cache previously loaded translations + self._gtobjs = {} + + # Get our language + self.lang = AppriseLocale.detect_language(language) + + # Our mapping to our _fn + self.__fn_map = None + + if GETTEXT_LOADED is False: + # We're done + return + + # Add language + self.add(self.lang) + + def add(self, lang=None, set_default=True): + """ + Add a language to our list + """ + lang = lang if lang else self._default_language + if lang not in self._gtobjs: + # Load our gettext object and install our language + try: + self._gtobjs[lang] = gettext.translation( + self._domain, localedir=self._locale_dir, languages=[lang], + fallback=False) + + # The non-intrusive method of applying the gettext change to + # the global namespace only + self.__fn_map = getattr(self._gtobjs[lang], self._fn) + + except FileNotFoundError: + # The translation directory does not exist + logger.debug( + 'Could not load translation path: %s', + join(self._locale_dir, lang)) + + # Fallback (handle case where self.lang does not exist) + if self.lang not in self._gtobjs: + self._gtobjs[self.lang] = gettext + self.__fn_map = getattr(self._gtobjs[self.lang], self._fn) + + return False + + logger.trace('Loaded language %s', lang) + + if set_default: + logger.debug('Language set to %s', lang) + self.lang = lang + + return True + + @contextlib.contextmanager + def lang_at(self, lang, mapto=_fn): + """ + The syntax works as: + with at.lang_at('fr'): + # apprise works as though the french language has been + # defined. afterwards, the language falls back to whatever + # it was. + """ + + if GETTEXT_LOADED is False: + # Do nothing + yield None + + # we're done + return + + # Tidy the language + lang = AppriseLocale.detect_language(lang, detect_fallback=False) + if lang not in self._gtobjs and not self.add(lang, set_default=False): + # Do Nothing + yield getattr(self._gtobjs[self.lang], mapto) + else: + # Yield + yield getattr(self._gtobjs[lang], mapto) + + return + + @property + def gettext(self): + """ + Return the current language gettext() function + + Useful for assigning to `_` + """ + return self._gtobjs[self.lang].gettext + + @staticmethod + def detect_language(lang=None, detect_fallback=True): + """ + Returns the language (if it's retrievable) + """ + # We want to only use the 2 character version of this language + # hence en_CA becomes en, en_US becomes en. + if not isinstance(lang, str): + if detect_fallback is False: + # no detection enabled; we're done + return None + + # Posix lookup + lookup = os.environ.get + localename = None + for variable in ('LC_ALL', 'LC_CTYPE', 'LANG', 'LANGUAGE'): + localename = lookup(variable, None) + if localename: + result = AppriseLocale._local_re.match(localename) + if result and result.group('lang'): + return result.group('lang').lower() + + # Windows handling + if hasattr(ctypes, 'windll'): + windll = ctypes.windll.kernel32 + try: + lang = locale.windows_locale[ + windll.GetUserDefaultUILanguage()] + + # Our detected windows language + return lang[0:2].lower() + + except (TypeError, KeyError): + # Fallback to posix detection + pass + + # Built in locale library check + try: + # Acquire our locale + lang = locale.getlocale()[0] + + except (ValueError, TypeError) as e: + # This occurs when an invalid locale was parsed from the + # environment variable. While we still return None in this + # case, we want to better notify the end user of this. Users + # receiving this error should check their environment + # variables. + logger.warning( + 'Language detection failure / {}'.format(str(e))) + return None + + return None if not lang else lang[0:2].lower() + + def __getstate__(self): + """ + Pickle Support dumps() + """ + state = self.__dict__.copy() + + # Remove the unpicklable entries. + del state['_gtobjs'] + del state['_AppriseLocale__fn_map'] + return state + + def __setstate__(self, state): + """ + Pickle Support loads() + """ + self.__dict__.update(state) + # Our mapping to our _fn + self.__fn_map = None + self._gtobjs = {} + self.add(state['lang'], set_default=True) + + +# +# Prepare our default LOCALE Singleton +# +LOCALE = AppriseLocale() class LazyTranslation: @@ -77,7 +275,7 @@ class LazyTranslation: super().__init__(*args, **kwargs) def __str__(self): - return gettext.gettext(self.text) + return LOCALE.gettext(self.text) if GETTEXT_LOADED else self.text # Lazy translation handling @@ -86,140 +284,3 @@ def gettext_lazy(text): A dummy function that can be referenced """ return LazyTranslation(text=text) - - -class AppriseLocale: - """ - A wrapper class to gettext so that we can manipulate multiple lanaguages - on the fly if required. - - """ - - def __init__(self, language=None): - """ - Initializes our object, if a language is specified, then we - initialize ourselves to that, otherwise we use whatever we detect - from the local operating system. If all else fails, we resort to the - defined default_language. - - """ - - # Cache previously loaded translations - self._gtobjs = {} - - # Get our language - self.lang = AppriseLocale.detect_language(language) - - if GETTEXT_LOADED is False: - # We're done - return - - if self.lang: - # Load our gettext object and install our language - try: - self._gtobjs[self.lang] = gettext.translation( - DOMAIN, localedir=LOCALE_DIR, languages=[self.lang]) - - # Install our language - self._gtobjs[self.lang].install() - - except IOError: - # This occurs if we can't access/load our translations - pass - - @contextlib.contextmanager - def lang_at(self, lang): - """ - The syntax works as: - with at.lang_at('fr'): - # apprise works as though the french language has been - # defined. afterwards, the language falls back to whatever - # it was. - """ - - if GETTEXT_LOADED is False: - # yield - yield - - # we're done - return - - # Tidy the language - lang = AppriseLocale.detect_language(lang, detect_fallback=False) - - # Now attempt to load it - try: - if lang in self._gtobjs: - if lang != self.lang: - # Install our language only if we aren't using it - # already - self._gtobjs[lang].install() - - else: - self._gtobjs[lang] = gettext.translation( - DOMAIN, localedir=LOCALE_DIR, languages=[self.lang]) - - # Install our language - self._gtobjs[lang].install() - - # Yield - yield - - except (IOError, KeyError): - # This occurs if we can't access/load our translations - # Yield reguardless - yield - - finally: - # Fall back to our previous language - if lang != self.lang and lang in self._gtobjs: - # Install our language - self._gtobjs[self.lang].install() - - return - - @staticmethod - def detect_language(lang=None, detect_fallback=True): - """ - returns the language (if it's retrievable) - """ - # We want to only use the 2 character version of this language - # hence en_CA becomes en, en_US becomes en. - if not isinstance(lang, str): - if detect_fallback is False: - # no detection enabled; we're done - return None - - if hasattr(ctypes, 'windll'): - windll = ctypes.windll.kernel32 - try: - lang = locale.windows_locale[ - windll.GetUserDefaultUILanguage()] - - # Our detected windows language - return lang[0:2].lower() - - except (TypeError, KeyError): - # Fallback to posix detection - pass - - try: - # Detect language - lang = locale.getdefaultlocale()[0] - - except ValueError as e: - # This occurs when an invalid locale was parsed from the - # environment variable. While we still return None in this - # case, we want to better notify the end user of this. Users - # receiving this error should check their environment - # variables. - logger.warning( - 'Language detection failure / {}'.format(str(e))) - return None - - except TypeError: - # None is returned if the default can't be determined - # we're done in this case - return None - - return None if not lang else lang[0:2].lower() diff --git a/lib/apprise/URLBase.py b/lib/apprise/URLBase.py index c0036e19..1cea66d1 100644 --- a/lib/apprise/URLBase.py +++ b/lib/apprise/URLBase.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -# BSD 3-Clause License +# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2023, Chris Caron @@ -14,10 +14,6 @@ # 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 @@ -204,7 +200,14 @@ class URLBase: self.verify_certificate = parse_bool(kwargs.get('verify', True)) # Secure Mode - self.secure = kwargs.get('secure', False) + self.secure = kwargs.get('secure', None) + try: + if not isinstance(self.secure, bool): + # Attempt to detect + self.secure = kwargs.get('schema', '')[-1].lower() == 's' + + except (TypeError, IndexError): + self.secure = False self.host = URLBase.unquote(kwargs.get('host')) self.port = kwargs.get('port') @@ -228,6 +231,11 @@ class URLBase: # Always unquote the password if it exists self.password = URLBase.unquote(self.password) + # Store our full path consistently ensuring it ends with a `/' + self.fullpath = URLBase.unquote(kwargs.get('fullpath')) + if not isinstance(self.fullpath, str) or not self.fullpath: + self.fullpath = '/' + # Store our Timeout Variables if 'rto' in kwargs: try: @@ -307,7 +315,36 @@ class URLBase: arguments provied. """ - raise NotImplementedError("url() is implimented by the child class.") + + # Our default parameters + params = self.url_parameters(privacy=privacy, *args, **kwargs) + + # Determine Authentication + auth = '' + if self.user and self.password: + auth = '{user}:{password}@'.format( + user=URLBase.quote(self.user, safe=''), + password=self.pprint( + self.password, privacy, mode=PrivacyMode.Secret, safe=''), + ) + elif self.user: + auth = '{user}@'.format( + user=URLBase.quote(self.user, safe=''), + ) + + default_port = 443 if self.secure else 80 + + return '{schema}://{auth}{hostname}{port}{fullpath}?{params}'.format( + schema='https' if self.secure else 'http', + auth=auth, + # never encode hostname since we're expecting it to be a valid one + hostname=self.host, + port='' if self.port is None or self.port == default_port + else ':{}'.format(self.port), + fullpath=URLBase.quote(self.fullpath, safe='/') + if self.fullpath else '/', + params=URLBase.urlencode(params), + ) def __contains__(self, tags): """ @@ -583,6 +620,33 @@ class URLBase: """ return (self.socket_connect_timeout, self.socket_read_timeout) + @property + def request_auth(self): + """This is primarily used to fullfill the `auth` keyword argument + that is used by requests.get() and requests.put() calls. + """ + return (self.user, self.password) if self.user else None + + @property + def request_url(self): + """ + Assemble a simple URL that can be used by the requests library + + """ + + # Acquire our schema + schema = 'https' if self.secure else 'http' + + # Prepare our URL + url = '%s://%s' % (schema, self.host) + + # Apply Port information if present + if isinstance(self.port, int): + url += ':%d' % self.port + + # Append our full path + return url + self.fullpath + def url_parameters(self, *args, **kwargs): """ Provides a default set of args to work with. This can greatly @@ -603,7 +667,8 @@ class URLBase: } @staticmethod - def parse_url(url, verify_host=True, plus_to_space=False): + def parse_url(url, verify_host=True, plus_to_space=False, + strict_port=False): """Parses the URL and returns it broken apart into a dictionary. This is very specific and customized for Apprise. @@ -624,13 +689,13 @@ class URLBase: results = parse_url( url, default_schema='unknown', verify_host=verify_host, - plus_to_space=plus_to_space) + plus_to_space=plus_to_space, strict_port=strict_port) if not results: # We're done; we failed to parse our url return results - # if our URL ends with an 's', then assueme our secure flag is set. + # if our URL ends with an 's', then assume our secure flag is set. results['secure'] = (results['schema'][-1] == 's') # Support SSL Certificate 'verify' keyword. Default to being enabled @@ -650,6 +715,21 @@ class URLBase: if 'user' in results['qsd']: results['user'] = results['qsd']['user'] + # parse_url() always creates a 'password' and 'user' entry in the + # results returned. Entries are set to None if they weren't specified + if results['password'] is None and 'user' in results['qsd']: + # Handle cases where the user= provided in 2 locations, we want + # the original to fall back as a being a password (if one wasn't + # otherwise defined) + # e.g. + # mailtos://PASSWORD@hostname?user=admin@mail-domain.com + # - the PASSWORD gets lost in the parse url() since a user= + # over-ride is specified. + presults = parse_url(results['url']) + if presults: + # Store our Password + results['password'] = presults['user'] + # Store our socket read timeout if specified if 'rto' in results['qsd']: results['rto'] = results['qsd']['rto'] @@ -685,6 +765,15 @@ class URLBase: return response + def __len__(self): + """ + Should be over-ridden and allows the tracking of how many targets + are associated with each URLBase object. + + Default is always 1 + """ + return 1 + def schemas(self): """A simple function that returns a set of all schemas associated with this object based on the object.protocol and diff --git a/lib/apprise/__init__.py b/lib/apprise/__init__.py index e67ce953..f8bb5c75 100644 --- a/lib/apprise/__init__.py +++ b/lib/apprise/__init__.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -# BSD 3-Clause License +# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2023, Chris Caron @@ -14,10 +14,6 @@ # 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 @@ -31,7 +27,7 @@ # POSSIBILITY OF SUCH DAMAGE. __title__ = 'Apprise' -__version__ = '1.3.0' +__version__ = '1.6.0' __author__ = 'Chris Caron' __license__ = 'BSD' __copywrite__ = 'Copyright (C) 2023 Chris Caron ' diff --git a/lib/apprise/attachment/AttachBase.py b/lib/apprise/attachment/AttachBase.py index 2b05c849..c1cadbf9 100644 --- a/lib/apprise/attachment/AttachBase.py +++ b/lib/apprise/attachment/AttachBase.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -# BSD 3-Clause License +# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2023, Chris Caron @@ -14,10 +14,6 @@ # 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 @@ -68,7 +64,8 @@ class AttachBase(URLBase): # set to zero (0), then no check is performed # 1 MB = 1048576 bytes # 5 MB = 5242880 bytes - max_file_size = 5242880 + # 1 GB = 1048576000 bytes + max_file_size = 1048576000 # By default all attachments types are inaccessible. # Developers of items identified in the attachment plugin directory diff --git a/lib/apprise/attachment/AttachFile.py b/lib/apprise/attachment/AttachFile.py index f89b915e..d3085555 100644 --- a/lib/apprise/attachment/AttachFile.py +++ b/lib/apprise/attachment/AttachFile.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -# BSD 3-Clause License +# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2023, Chris Caron @@ -14,10 +14,6 @@ # 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 diff --git a/lib/apprise/attachment/AttachHTTP.py b/lib/apprise/attachment/AttachHTTP.py index d8b46ff2..0c859477 100644 --- a/lib/apprise/attachment/AttachHTTP.py +++ b/lib/apprise/attachment/AttachHTTP.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -# BSD 3-Clause License +# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2023, Chris Caron @@ -14,10 +14,6 @@ # 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 diff --git a/lib/apprise/attachment/__init__.py b/lib/apprise/attachment/__init__.py index 1b0e1bfe..ba7620a4 100644 --- a/lib/apprise/attachment/__init__.py +++ b/lib/apprise/attachment/__init__.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -# BSD 3-Clause License +# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2023, Chris Caron @@ -14,10 +14,6 @@ # 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 diff --git a/lib/apprise/common.py b/lib/apprise/common.py index 2a5a0162..5e3a3567 100644 --- a/lib/apprise/common.py +++ b/lib/apprise/common.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -# BSD 3-Clause License +# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2023, Chris Caron @@ -14,10 +14,6 @@ # 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 diff --git a/lib/apprise/config/ConfigBase.py b/lib/apprise/config/ConfigBase.py index 2f3e33b3..0da7a8be 100644 --- a/lib/apprise/config/ConfigBase.py +++ b/lib/apprise/config/ConfigBase.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -# BSD 3-Clause License +# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2023, Chris Caron @@ -14,10 +14,6 @@ # 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 @@ -355,6 +351,77 @@ class ConfigBase(URLBase): # missing and/or expired. return True + @staticmethod + def __normalize_tag_groups(group_tags): + """ + Used to normalize a tag assign map which looks like: + { + 'group': set('{tag1}', '{group1}', '{tag2}'), + 'group1': set('{tag2}','{tag3}'), + } + + Then normalized it (merging groups); with respect to the above, the + output would be: + { + 'group': set('{tag1}', '{tag2}', '{tag3}), + 'group1': set('{tag2}','{tag3}'), + } + + """ + # Prepare a key set list we can use + tag_groups = set([str(x) for x in group_tags.keys()]) + + def _expand(tags, ignore=None): + """ + Expands based on tag provided and returns a set + + this also updates the group_tags while it goes + """ + + # Prepare ourselves a return set + results = set() + ignore = set() if ignore is None else ignore + + # track groups + groups = set() + + for tag in tags: + if tag in ignore: + continue + + # Track our groups + groups.add(tag) + + # Store what we know is worth keping + results |= group_tags[tag] - tag_groups + + # Get simple tag assignments + found = group_tags[tag] & tag_groups + if not found: + continue + + for gtag in found: + if gtag in ignore: + continue + + # Go deeper (recursion) + ignore.add(tag) + group_tags[gtag] = _expand(set([gtag]), ignore=ignore) + results |= group_tags[gtag] + + # Pop ignore + ignore.remove(tag) + + return results + + for tag in tag_groups: + # Get our tags + group_tags[tag] |= _expand(set([tag])) + if not group_tags[tag]: + ConfigBase.logger.warning( + 'The group {} has no tags assigned to it'.format(tag)) + del group_tags[tag] + @staticmethod def parse_url(url, verify_host=True): """Parses the URL and returns it broken apart into a dictionary. @@ -533,6 +600,9 @@ class ConfigBase(URLBase): # as additional configuration entries when loaded. include + # Assign tag contents to a group identifier + = + """ # A list of loaded Notification Services servers = list() @@ -541,6 +611,12 @@ class ConfigBase(URLBase): # the include keyword configs = list() + # Track all of the tags we want to assign later on + group_tags = {} + + # Track our entries to preload + preloaded = [] + # Prepare our Asset Object asset = asset if isinstance(asset, AppriseAsset) else AppriseAsset() @@ -548,7 +624,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}://.*)|' + r'((?P[a-z0-9]{1,12}://.*)|(?P[a-z0-9, \t_-]+))|' r'include\s+(?P.+))?\s*$', re.I) try: @@ -574,8 +650,13 @@ class ConfigBase(URLBase): # otherwise. return (list(), list()) - url, config = result.group('url'), result.group('config') - if not (url or config): + # Retrieve our line + url, assign, config = \ + result.group('url'), \ + result.group('assign'), \ + result.group('config') + + if not (url or config or assign): # Comment/empty line; do nothing continue @@ -595,6 +676,33 @@ class ConfigBase(URLBase): loggable_url = url if not asset.secure_logging \ else cwe312_url(url) + if assign: + groups = set(parse_list(result.group('tags'), cast=str)) + if not groups: + # no tags were assigned + ConfigBase.logger.warning( + 'Unparseable tag assignment - no group(s) ' + 'on line {}'.format(line)) + continue + + # Get our tags + tags = set(parse_list(assign, cast=str)) + if not tags: + # no tags were assigned + ConfigBase.logger.warning( + 'Unparseable tag assignment - no tag(s) to assign ' + 'on line {}'.format(line)) + continue + + # Update our tag group map + for tag_group in groups: + if tag_group not in group_tags: + group_tags[tag_group] = set() + + # ensure our tag group is never included in the assignment + group_tags[tag_group] |= tags - set([tag_group]) + continue + # Acquire our url tokens results = plugins.url_to_dict( url, secure_logging=asset.secure_logging) @@ -607,25 +715,57 @@ class ConfigBase(URLBase): # Build a list of tags to associate with the newly added # notifications if any were set - results['tag'] = set(parse_list(result.group('tags'))) + results['tag'] = set(parse_list(result.group('tags'), cast=str)) # Set our Asset Object results['asset'] = asset + # Store our preloaded entries + preloaded.append({ + 'results': results, + 'line': line, + 'loggable_url': loggable_url, + }) + + # + # Normalize Tag Groups + # - Expand Groups of Groups so that they don't exist + # + ConfigBase.__normalize_tag_groups(group_tags) + + # + # URL Processing + # + for entry in preloaded: + # Point to our results entry for easier reference below + results = entry['results'] + + # + # Apply our tag groups if they're defined + # + for group, tags in group_tags.items(): + # Detect if anything assigned to this tag also maps back to a + # group. If so we want to add the group to our list + if next((True for tag in results['tag'] + if tag in tags), False): + results['tag'].add(group) + try: # Attempt to create an instance of our plugin using the # parsed URL information - plugin = common.NOTIFY_SCHEMA_MAP[results['schema']](**results) + plugin = common.NOTIFY_SCHEMA_MAP[ + results['schema']](**results) # Create log entry of loaded URL ConfigBase.logger.debug( - 'Loaded URL: %s', plugin.url(privacy=asset.secure_logging)) + 'Loaded URL: %s', plugin.url( + privacy=results['asset'].secure_logging)) except Exception as e: # the arguments are invalid or can not be used. ConfigBase.logger.warning( 'Could not load URL {} on line {}.'.format( - loggable_url, line)) + entry['loggable_url'], entry['line'])) ConfigBase.logger.debug('Loading Exception: %s' % str(e)) continue diff --git a/lib/apprise/config/ConfigFile.py b/lib/apprise/config/ConfigFile.py index bfb869a1..a0b9bf69 100644 --- a/lib/apprise/config/ConfigFile.py +++ b/lib/apprise/config/ConfigFile.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -# BSD 3-Clause License +# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2023, Chris Caron @@ -14,10 +14,6 @@ # 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 diff --git a/lib/apprise/config/ConfigHTTP.py b/lib/apprise/config/ConfigHTTP.py index 244b45d3..82cb1f63 100644 --- a/lib/apprise/config/ConfigHTTP.py +++ b/lib/apprise/config/ConfigHTTP.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -# BSD 3-Clause License +# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2023, Chris Caron @@ -14,10 +14,6 @@ # 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 diff --git a/lib/apprise/config/ConfigMemory.py b/lib/apprise/config/ConfigMemory.py index ec44e9b4..110e04a3 100644 --- a/lib/apprise/config/ConfigMemory.py +++ b/lib/apprise/config/ConfigMemory.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -# BSD 3-Clause License +# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2023, Chris Caron @@ -14,10 +14,6 @@ # 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 diff --git a/lib/apprise/config/__init__.py b/lib/apprise/config/__init__.py index 7d03a34a..4b7e3fd7 100644 --- a/lib/apprise/config/__init__.py +++ b/lib/apprise/config/__init__.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -# BSD 3-Clause License +# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2023, Chris Caron @@ -14,10 +14,6 @@ # 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 diff --git a/lib/apprise/conversion.py b/lib/apprise/conversion.py index dc4f1169..ffa3e3a0 100644 --- a/lib/apprise/conversion.py +++ b/lib/apprise/conversion.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -# BSD 3-Clause License +# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2023, Chris Caron @@ -14,10 +14,6 @@ # 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 diff --git a/lib/apprise/decorators/CustomNotifyPlugin.py b/lib/apprise/decorators/CustomNotifyPlugin.py index 9c8e7cb1..5ccfded5 100644 --- a/lib/apprise/decorators/CustomNotifyPlugin.py +++ b/lib/apprise/decorators/CustomNotifyPlugin.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -# BSD 3-Clause License +# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2023, Chris Caron @@ -14,10 +14,6 @@ # 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 @@ -28,6 +24,7 @@ # 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.USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. from ..plugins.NotifyBase import NotifyBase diff --git a/lib/apprise/decorators/__init__.py b/lib/apprise/decorators/__init__.py index 699fd0da..5b089bbf 100644 --- a/lib/apprise/decorators/__init__.py +++ b/lib/apprise/decorators/__init__.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -# BSD 3-Clause License +# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2023, Chris Caron @@ -14,10 +14,6 @@ # 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 diff --git a/lib/apprise/decorators/notify.py b/lib/apprise/decorators/notify.py index 36842b41..07b4ceb1 100644 --- a/lib/apprise/decorators/notify.py +++ b/lib/apprise/decorators/notify.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -# BSD 3-Clause License +# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2023, Chris Caron @@ -14,10 +14,6 @@ # 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 diff --git a/lib/apprise/i18n/apprise.pot b/lib/apprise/i18n/apprise.pot index 677814fe..434ce91d 100644 --- a/lib/apprise/i18n/apprise.pot +++ b/lib/apprise/i18n/apprise.pot @@ -6,16 +6,16 @@ #, fuzzy msgid "" msgstr "" -"Project-Id-Version: apprise 1.3.0\n" +"Project-Id-Version: apprise 1.6.0\n" "Report-Msgid-Bugs-To: lead2gold@gmail.com\n" -"POT-Creation-Date: 2023-02-22 17:31-0500\n" +"POT-Creation-Date: 2023-10-15 15:56-0400\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" -"Generated-By: Babel 2.9.1\n" +"Generated-By: Babel 2.11.0\n" msgid "A local Gnome environment is required." msgstr "" @@ -164,6 +164,9 @@ msgstr "" msgid "Consumer Secret" msgstr "" +msgid "Content Placement" +msgstr "" + msgid "Country" msgstr "" @@ -209,6 +212,9 @@ msgstr "" msgid "Device Name" msgstr "" +msgid "Discord Event ID" +msgstr "" + msgid "Display Footer" msgstr "" @@ -224,12 +230,6 @@ msgstr "" msgid "Email Header" msgstr "" -msgid "Encrypted Password" -msgstr "" - -msgid "Encrypted Salt" -msgstr "" - msgid "Entity" msgstr "" @@ -272,6 +272,9 @@ msgstr "" msgid "From Name" msgstr "" +msgid "From Phone ID" +msgstr "" + msgid "From Phone No" msgstr "" @@ -317,6 +320,9 @@ msgstr "" msgid "Integration ID" msgstr "" +msgid "Integration Key" +msgstr "" + msgid "Is Ad?" msgstr "" @@ -353,6 +359,9 @@ msgstr "" msgid "Master Key" msgstr "" +msgid "Matrix API Verion" +msgstr "" + msgid "Memory" msgstr "" @@ -433,6 +442,12 @@ msgstr "" msgid "Payload Extras" msgstr "" +msgid "Ping Discord Role" +msgstr "" + +msgid "Ping Discord User" +msgstr "" + msgid "Port" msgstr "" @@ -451,9 +466,15 @@ msgstr "" msgid "Provider Key" msgstr "" +msgid "Pushkey" +msgstr "" + msgid "QOS" msgstr "" +msgid "Query Method" +msgstr "" + msgid "Region" msgstr "" @@ -475,24 +496,27 @@ msgstr "" msgid "Retry" msgstr "" -msgid "Rooms" -msgstr "" - -msgid "Route" +msgid "Room ID" msgstr "" msgid "Route Group" msgstr "" -msgid "Routing Key" -msgstr "" - msgid "SMTP Server" msgstr "" +msgid "Salt" +msgstr "" + msgid "Schema" msgstr "" +msgid "Secret" +msgstr "" + +msgid "Secret API Key" +msgstr "" + msgid "Secret Access Key" msgstr "" @@ -520,6 +544,9 @@ msgstr "" msgid "Severity" msgstr "" +msgid "Short URL" +msgstr "" + msgid "Show Status" msgstr "" @@ -559,9 +586,6 @@ msgstr "" msgid "Subtitle" msgstr "" -msgid "Syslog Mode" -msgstr "" - msgid "Tags" msgstr "" @@ -658,6 +682,15 @@ msgstr "" msgid "Template Data" msgstr "" +msgid "Template ID" +msgstr "" + +msgid "Template Mapping" +msgstr "" + +msgid "Template Name" +msgstr "" + msgid "Template Path" msgstr "" @@ -706,12 +739,18 @@ msgstr "" msgid "Topic" msgstr "" +msgid "Topic Thread ID" +msgstr "" + msgid "Transmitter Groups" msgstr "" msgid "URL" msgstr "" +msgid "URL Prefix" +msgstr "" + msgid "URL Title" msgstr "" @@ -796,3 +835,6 @@ msgstr "" msgid "ttl" msgstr "" +msgid "validity" +msgstr "" + diff --git a/lib/apprise/i18n/en/LC_MESSAGES/apprise.po b/lib/apprise/i18n/en/LC_MESSAGES/apprise.po index 44451262..65deb777 100644 --- a/lib/apprise/i18n/en/LC_MESSAGES/apprise.po +++ b/lib/apprise/i18n/en/LC_MESSAGES/apprise.po @@ -3,9 +3,10 @@ # This file is distributed under the same license as the apprise project. # Chris Caron , 2019. # -msgid "" +msgid "" msgstr "" -"Project-Id-Version: apprise 0.7.6\n" + +"Project-Id-Version: apprise 1.4.5\n" "Report-Msgid-Bugs-To: lead2gold@gmail.com\n" "POT-Creation-Date: 2019-05-28 16:56-0400\n" "PO-Revision-Date: 2019-05-24 20:00-0400\n" @@ -18,276 +19,272 @@ msgstr "" "Content-Transfer-Encoding: 8bit\n" "Generated-By: Babel 2.6.0\n" -msgid "API Key" -msgstr "" +msgid "API Key" +msgstr "API Key" -msgid "Access Key" -msgstr "" +msgid "Access Key" +msgstr "Access Key" -msgid "Access Key ID" -msgstr "" +msgid "Access Key ID" +msgstr "Access Key ID" -msgid "Access Secret" -msgstr "" +msgid "Access Secret" +msgstr "Access Secret" -msgid "Access Token" -msgstr "" +msgid "Access Token" +msgstr "Access Token" -msgid "Account SID" -msgstr "" +msgid "Account SID" +msgstr "Account SID" -msgid "Add Tokens" -msgstr "" +msgid "Add Tokens" +msgstr "Add Tokens" -msgid "Application Key" -msgstr "" +msgid "Application Key" +msgstr "Application Key" -msgid "Application Secret" -msgstr "" +msgid "Application Secret" +msgstr "Application Secret" -msgid "Auth Token" -msgstr "" +msgid "Auth Token" +msgstr "Auth Token" -msgid "Authorization Token" -msgstr "" +msgid "Authorization Token" +msgstr "Authorization Token" -msgid "Avatar Image" -msgstr "" +msgid "Avatar Image" +msgstr "Avatar Image" -msgid "Bot Name" -msgstr "" +msgid "Bot Name" +msgstr "Bot Name" -msgid "Bot Token" -msgstr "" +msgid "Bot Token" +msgstr "Bot Token" -msgid "Channels" -msgstr "" +msgid "Channels" +msgstr "Channels" -msgid "Consumer Key" -msgstr "" +msgid "Consumer Key" +msgstr "Consumer Key" -msgid "Consumer Secret" -msgstr "" +msgid "Consumer Secret" +msgstr "Consumer Secret" -msgid "Detect Bot Owner" -msgstr "" +msgid "Detect Bot Owner" +msgstr "Detect Bot Owner" -msgid "Device ID" -msgstr "" +msgid "Device ID" +msgstr "Device ID" -msgid "Display Footer" -msgstr "" +msgid "Display Footer" +msgstr "Display Footer" -msgid "Domain" -msgstr "" +msgid "Domain" +msgstr "Domain" -msgid "Duration" -msgstr "" +msgid "Duration" +msgstr "Duration" -msgid "Events" -msgstr "" +msgid "Events" +msgstr "Events" -msgid "Footer Logo" -msgstr "" +msgid "Footer Logo" +msgstr "Footer Logo" -msgid "From Email" -msgstr "" +msgid "From Email" +msgstr "From Email" -msgid "From Name" -msgstr "" +msgid "From Name" +msgstr "From Name" -msgid "From Phone No" -msgstr "" +msgid "From Phone No" +msgstr "From Phone No" -msgid "Group" -msgstr "" +msgid "Group" +msgstr "Group" -msgid "HTTP Header" -msgstr "" +msgid "HTTP Header" +msgstr "HTTP Header" -msgid "Hostname" -msgstr "" +msgid "Hostname" +msgstr "Hostname" -msgid "Include Image" -msgstr "" +msgid "Include Image" +msgstr "Include Image" -msgid "Modal" -msgstr "" +msgid "Modal" +msgstr "Modal" -msgid "Notify Format" -msgstr "" +msgid "Notify Format" +msgstr "Notify Format" -msgid "Organization" -msgstr "" +msgid "Organization" +msgstr "Organization" -msgid "Overflow Mode" -msgstr "" +msgid "Overflow Mode" +msgstr "Overflow Mode" -msgid "Password" -msgstr "" +msgid "Password" +msgstr "Password" -msgid "Port" -msgstr "" +msgid "Port" +msgstr "Port" -msgid "Priority" -msgstr "" +msgid "Priority" +msgstr "Priority" -msgid "Provider Key" -msgstr "" +msgid "Provider Key" +msgstr "Provider Key" -msgid "Region" -msgstr "" +msgid "Region" +msgstr "Region" -msgid "Region Name" -msgstr "" +msgid "Region Name" +msgstr "Region Name" -msgid "Remove Tokens" -msgstr "" +msgid "Remove Tokens" +msgstr "Remove Tokens" -msgid "Rooms" -msgstr "" +msgid "Rooms" +msgstr "Rooms" -msgid "SMTP Server" -msgstr "" +msgid "SMTP Server" +msgstr "SMTP Server" -msgid "Schema" -msgstr "" +msgid "Schema" +msgstr "Schema" -msgid "Secret Access Key" -msgstr "" +msgid "Secret Access Key" +msgstr "Secret Access Key" -msgid "Secret Key" -msgstr "" +msgid "Secret Key" +msgstr "Secret Key" -msgid "Secure Mode" -msgstr "" +msgid "Secure Mode" +msgstr "Secure Mode" -msgid "Server Timeout" -msgstr "" +msgid "Server Timeout" +msgstr "Server Timeout" -msgid "Sound" -msgstr "" +msgid "Sound" +msgstr "Sound" -msgid "Source JID" -msgstr "" +msgid "Source JID" +msgstr "Source JID" -msgid "Target Channel" -msgstr "" +msgid "Target Channel" +msgstr "Target Channel" -msgid "Target Chat ID" -msgstr "" +msgid "Target Chat ID" +msgstr "Target Chat ID" -msgid "Target Device" -msgstr "" +msgid "Target Device" +msgstr "Target Device" -msgid "Target Device ID" -msgstr "" +msgid "Target Device ID" +msgstr "Target Device ID" -msgid "Target Email" -msgstr "" +msgid "Target Email" +msgstr "Target Email" -msgid "Target Emails" -msgstr "" +msgid "Target Emails" +msgstr "Target Emails" -msgid "Target Encoded ID" -msgstr "" +msgid "Target Encoded ID" +msgstr "Target Encoded ID" -msgid "Target JID" -msgstr "" +msgid "Target JID" +msgstr "Target JID" -msgid "Target Phone No" -msgstr "" +msgid "Target Phone No" +msgstr "Target Phone No" -msgid "Target Room Alias" -msgstr "" +msgid "Target Room Alias" +msgstr "Target Room Alias" -msgid "Target Room ID" -msgstr "" +msgid "Target Room ID" +msgstr "Target Room ID" -msgid "Target Short Code" -msgstr "" +msgid "Target Short Code" +msgstr "Target Short Code" -msgid "Target Tag ID" -msgstr "" +msgid "Target Tag ID" +msgstr "Target Tag ID" -msgid "Target Topic" -msgstr "" +msgid "Target Topic" +msgstr "Target Topic" -msgid "Target User" -msgstr "" +msgid "Target User" +msgstr "Target User" -msgid "Targets" -msgstr "" +msgid "Targets" +msgstr "Targets" -msgid "Text To Speech" -msgstr "" +msgid "Text To Speech" +msgstr "Text To Speech" -msgid "To Channel ID" -msgstr "" +msgid "To Channel ID" +msgstr "To Channel ID" -msgid "To Email" -msgstr "" +msgid "To Email" +msgstr "To Email" -msgid "To User ID" -msgstr "" +msgid "To User ID" +msgstr "To User ID" -msgid "Token" -msgstr "" +msgid "Token" +msgstr "Token" -msgid "Token A" -msgstr "" +msgid "Token A" +msgstr "Token A" -msgid "Token B" -msgstr "" +msgid "Token B" +msgstr "Token B" -msgid "Token C" -msgstr "" +msgid "Token C" +msgstr "Token C" -msgid "Urgency" -msgstr "" +msgid "Urgency" +msgstr "Urgency" -msgid "Use Avatar" -msgstr "" +msgid "Use Avatar" +msgstr "Use Avatar" -msgid "User" -msgstr "" +msgid "User" +msgstr "User" -msgid "User Key" -msgstr "" +msgid "User Key" +msgstr "User Key" -msgid "User Name" -msgstr "" +msgid "User Name" +msgstr "User Name" -msgid "Username" -msgstr "" +msgid "Username" +msgstr "Username" -msgid "Verify SSL" -msgstr "" +msgid "Verify SSL" +msgstr "Verify SSL" -msgid "Version" -msgstr "" +msgid "Version" +msgstr "Version" -msgid "Webhook" -msgstr "" +msgid "Webhook" +msgstr "Webhook" -msgid "Webhook ID" -msgstr "" +msgid "Webhook ID" +msgstr "Webhook ID" -msgid "Webhook Mode" -msgstr "" +msgid "Webhook Mode" +msgstr "Webhook Mode" -msgid "Webhook Token" -msgstr "" +msgid "Webhook Token" +msgstr "Webhook Token" -msgid "X-Axis" -msgstr "" +msgid "X-Axis" +msgstr "X-Axis" -msgid "XEP" -msgstr "" - -msgid "Y-Axis" -msgstr "" - -#~ msgid "Access Key Secret" -#~ msgstr "" +msgid "XEP" +msgstr "XEP" +msgid "Y-Axis" +msgstr "Y-Axis" diff --git a/lib/apprise/logger.py b/lib/apprise/logger.py index 005a3e0d..6a594ec6 100644 --- a/lib/apprise/logger.py +++ b/lib/apprise/logger.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -# BSD 3-Clause License +# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2023, Chris Caron @@ -14,10 +14,6 @@ # 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 diff --git a/lib/apprise/plugins/NotifyAppriseAPI.py b/lib/apprise/plugins/NotifyAppriseAPI.py index d2f1452a..3c85b8ac 100644 --- a/lib/apprise/plugins/NotifyAppriseAPI.py +++ b/lib/apprise/plugins/NotifyAppriseAPI.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -# BSD 3-Clause License +# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2023, Chris Caron @@ -14,10 +14,6 @@ # 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 @@ -33,6 +29,7 @@ import re import requests from json import dumps +import base64 from .NotifyBase import NotifyBase from ..URLBase import PrivacyMode @@ -42,6 +39,20 @@ from ..utils import validate_regex from ..AppriseLocale import gettext_lazy as _ +class AppriseAPIMethod: + """ + Defines the method to post data tot he remote server + """ + JSON = 'json' + FORM = 'form' + + +APPRISE_API_METHODS = ( + AppriseAPIMethod.FORM, + AppriseAPIMethod.JSON, +) + + class NotifyAppriseAPI(NotifyBase): """ A wrapper for Apprise (Persistent) API Notifications @@ -62,9 +73,12 @@ class NotifyAppriseAPI(NotifyBase): # A URL that takes you to the setup/help of the specific protocol setup_url = 'https://github.com/caronc/apprise/wiki/Notify_apprise_api' + # Support attachments + attachment_support = True + # Depending on the number of transactions/notifications taking place, this # could take a while. 30 seconds should be enough to perform the task - socket_connect_timeout = 30.0 + socket_read_timeout = 30.0 # Disable throttle rate for Apprise API requests since they are normally # local anyway @@ -119,6 +133,12 @@ class NotifyAppriseAPI(NotifyBase): 'name': _('Tags'), 'type': 'string', }, + 'method': { + 'name': _('Query Method'), + 'type': 'choice:string', + 'values': APPRISE_API_METHODS, + 'default': APPRISE_API_METHODS[0], + }, 'to': { 'alias_of': 'token', }, @@ -132,7 +152,8 @@ class NotifyAppriseAPI(NotifyBase): }, } - def __init__(self, token=None, tags=None, headers=None, **kwargs): + def __init__(self, token=None, tags=None, method=None, headers=None, + **kwargs): """ Initialize Apprise API Object @@ -142,10 +163,6 @@ class NotifyAppriseAPI(NotifyBase): """ super().__init__(**kwargs) - self.fullpath = kwargs.get('fullpath') - if not isinstance(self.fullpath, str): - self.fullpath = '/' - self.token = validate_regex( token, *self.template_tokens['token']['regex']) if not self.token: @@ -154,6 +171,14 @@ class NotifyAppriseAPI(NotifyBase): self.logger.warning(msg) raise TypeError(msg) + self.method = self.template_args['method']['default'] \ + if not isinstance(method, str) else method.lower() + + if self.method not in APPRISE_API_METHODS: + msg = 'The method specified ({}) is invalid.'.format(method) + self.logger.warning(msg) + raise TypeError(msg) + # Build list of tags self.__tags = parse_list(tags) @@ -169,8 +194,13 @@ class NotifyAppriseAPI(NotifyBase): Returns the URL built dynamically based on specified arguments. """ - # Our URL parameters - params = self.url_parameters(privacy=privacy, *args, **kwargs) + # Define any URL parameters + params = { + 'method': self.method, + } + + # Extend our parameters + params.update(self.url_parameters(privacy=privacy, *args, **kwargs)) # Append our headers into our parameters params.update({'+{}'.format(k): v for k, v in self.headers.items()}) @@ -209,15 +239,61 @@ class NotifyAppriseAPI(NotifyBase): token=self.pprint(self.token, privacy, safe=''), params=NotifyAppriseAPI.urlencode(params)) - def send(self, body, title='', notify_type=NotifyType.INFO, **kwargs): + def send(self, body, title='', notify_type=NotifyType.INFO, attach=None, + **kwargs): """ Perform Apprise API Notification """ - headers = {} + # Prepare HTTP Headers + headers = { + 'User-Agent': self.app_id, + } + # Apply any/all header over-rides defined headers.update(self.headers) + attachments = [] + files = [] + if attach and self.attachment_support: + for no, attachment in enumerate(attach, start=1): + # Perform some simple error checking + if not attachment: + # We could not access the attachment + self.logger.error( + 'Could not access attachment {}.'.format( + attachment.url(privacy=True))) + return False + + try: + if self.method == AppriseAPIMethod.JSON: + with open(attachment.path, 'rb') as f: + # Output must be in a DataURL format (that's what + # PushSafer calls it): + attachments.append({ + 'filename': attachment.name, + 'base64': base64.b64encode(f.read()) + .decode('utf-8'), + 'mimetype': attachment.mimetype, + }) + + else: # AppriseAPIMethod.FORM + files.append(( + 'file{:02d}'.format(no), + ( + attachment.name, + open(attachment.path, 'rb'), + attachment.mimetype, + ) + )) + + except (OSError, IOError) as e: + self.logger.warning( + 'An I/O error occurred while reading {}.'.format( + attachment.name if attachment else 'attachment')) + self.logger.debug('I/O Exception: %s' % str(e)) + return False + # prepare Apprise API Object payload = { # Apprise API Payload @@ -227,6 +303,14 @@ class NotifyAppriseAPI(NotifyBase): 'format': self.notify_format, } + if self.method == AppriseAPIMethod.JSON: + headers['Content-Type'] = 'application/json' + + if attachments: + payload['attachments'] = attachments + + payload = dumps(payload) + if self.__tags: payload['tag'] = self.__tags @@ -242,13 +326,13 @@ class NotifyAppriseAPI(NotifyBase): url += ':%d' % self.port fullpath = self.fullpath.strip('/') - url += '/{}/'.format(fullpath) if fullpath else '/' - url += 'notify/{}'.format(self.token) + url += '{}'.format('/' + fullpath) if fullpath else '' + url += '/notify/{}'.format(self.token) # Some entries can not be over-ridden headers.update({ - 'User-Agent': self.app_id, - 'Content-Type': 'application/json', + # Our response to be in JSON format always + 'Accept': 'application/json', # Pass our Source UUID4 Identifier 'X-Apprise-ID': self.asset._uid, # Pass our current recursion count to our upstream server @@ -266,9 +350,10 @@ class NotifyAppriseAPI(NotifyBase): try: r = requests.post( url, - data=dumps(payload), + data=payload, headers=headers, auth=auth, + files=files if files else None, verify=self.verify_certificate, timeout=self.request_timeout, ) @@ -290,7 +375,8 @@ class NotifyAppriseAPI(NotifyBase): return False else: - self.logger.info('Sent Apprise API notification.') + self.logger.info( + 'Sent Apprise API notification; method=%s.', self.method) except requests.RequestException as e: self.logger.warning( @@ -301,6 +387,18 @@ class NotifyAppriseAPI(NotifyBase): # Return; we're done return False + except (OSError, IOError) as e: + self.logger.warning( + 'An I/O error occurred while reading one of the ' + 'attached files.') + self.logger.debug('I/O Exception: %s' % str(e)) + return False + + finally: + for file in files: + # Ensure all files are closed + file[1][1].close() + return True @staticmethod @@ -377,4 +475,9 @@ class NotifyAppriseAPI(NotifyBase): # re-assemble our full path results['fullpath'] = '/'.join(entries) + # Set method if specified + if 'method' in results['qsd'] and len(results['qsd']['method']): + results['method'] = \ + NotifyAppriseAPI.unquote(results['qsd']['method']) + return results diff --git a/lib/apprise/plugins/NotifyBark.py b/lib/apprise/plugins/NotifyBark.py index 923788de..edef82bd 100644 --- a/lib/apprise/plugins/NotifyBark.py +++ b/lib/apprise/plugins/NotifyBark.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -# BSD 3-Clause License +# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2023, Chris Caron @@ -14,10 +14,6 @@ # 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 @@ -127,10 +123,10 @@ class NotifyBark(NotifyBase): # Define object templates templates = ( + '{schema}://{host}/{targets}', '{schema}://{host}:{port}/{targets}', '{schema}://{user}:{password}@{host}/{targets}', '{schema}://{user}:{password}@{host}:{port}/{targets}', - '{schema}://{user}:{password}@{host}/{targets}', ) # Define our template arguments @@ -163,6 +159,7 @@ class NotifyBark(NotifyBase): 'targets': { 'name': _('Targets'), 'type': 'list:string', + 'required': True, }, }) @@ -280,7 +277,7 @@ class NotifyBark(NotifyBase): # error tracking (used for function return) has_error = False - if not len(self.targets): + if not self.targets: # We have nothing to notify; we're done self.logger.warning('There are no Bark devices to notify') return False @@ -456,6 +453,12 @@ class NotifyBark(NotifyBase): params=NotifyBark.urlencode(params), ) + def __len__(self): + """ + Returns the number of targets associated with this notification + """ + return len(self.targets) + @staticmethod def parse_url(url): """ diff --git a/lib/apprise/plugins/NotifyBase.py b/lib/apprise/plugins/NotifyBase.py index 1b07baa7..5138c15c 100644 --- a/lib/apprise/plugins/NotifyBase.py +++ b/lib/apprise/plugins/NotifyBase.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -# BSD 3-Clause License +# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2023, Chris Caron @@ -14,10 +14,6 @@ # 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 @@ -139,6 +135,18 @@ class NotifyBase(URLBase): # Default Overflow Mode overflow_mode = OverflowMode.UPSTREAM + # Support Attachments; this defaults to being disabled. + # Since apprise allows you to send attachments without a body or title + # defined, by letting Apprise know the plugin won't support attachments + # up front, it can quickly pass over and ignore calls to these end points. + + # You must set this to true if your application can handle attachments. + # You must also consider a flow change to your notification if this is set + # to True as well as now there will be cases where both the body and title + # may not be set. There will never be a case where a body, or attachment + # isn't set in the same call to your notify() function. + attachment_support = False + # Default Title HTML Tagging # When a title is specified for a notification service that doesn't accept # titles, by default apprise tries to give a plesant view and convert the @@ -316,7 +324,7 @@ class NotifyBase(URLBase): the_cors = (do_send(**kwargs2) for kwargs2 in send_calls) return all(await asyncio.gather(*the_cors)) - def _build_send_calls(self, body, title=None, + def _build_send_calls(self, body=None, title=None, notify_type=NotifyType.INFO, overflow=None, attach=None, body_format=None, **kwargs): """ @@ -339,6 +347,28 @@ class NotifyBase(URLBase): # bad attachments raise + # Handle situations where the body is None + body = '' if not body else body + + elif not (body or attach): + # If there is not an attachment at the very least, a body must be + # present + msg = "No message body or attachment was specified." + self.logger.warning(msg) + raise TypeError(msg) + + if not body and not self.attachment_support: + # If no body was specified, then we know that an attachment + # was. This is logic checked earlier in the code. + # + # Knowing this, if the plugin itself doesn't support sending + # attachments, there is nothing further to do here, just move + # along. + msg = f"{self.service_name} does not support attachments; " \ + " service skipped" + self.logger.warning(msg) + raise TypeError(msg) + # Handle situations where the title is None title = '' if not title else title diff --git a/lib/apprise/plugins/NotifyBoxcar.py b/lib/apprise/plugins/NotifyBoxcar.py index a613e46e..9d3be6ae 100644 --- a/lib/apprise/plugins/NotifyBoxcar.py +++ b/lib/apprise/plugins/NotifyBoxcar.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -# BSD 3-Clause License +# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2023, Chris Caron @@ -14,10 +14,6 @@ # 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 @@ -46,6 +42,7 @@ except ImportError: from .NotifyBase import NotifyBase from ..URLBase import PrivacyMode from ..utils import parse_bool +from ..utils import parse_list from ..utils import validate_regex from ..common import NotifyType from ..common import NotifyImageSize @@ -58,7 +55,7 @@ DEFAULT_TAG = '@all' # list of tagged devices that the notification need to be send to, and a # boolean operator (‘and’ / ‘or’) that defines the criteria to match devices # against those tags. -IS_TAG = re.compile(r'^[@](?P[A-Z0-9]{1,63})$', re.I) +IS_TAG = re.compile(r'^[@]?(?P[A-Z0-9]{1,63})$', re.I) # Device tokens are only referenced when developing. # It's not likely you'll send a message directly to a device, but if you do; @@ -150,6 +147,12 @@ class NotifyBoxcar(NotifyBase): 'to': { 'alias_of': 'targets', }, + 'access': { + 'alias_of': 'access_key', + }, + 'secret': { + 'alias_of': 'secret_key', + }, }) def __init__(self, access, secret, targets=None, include_image=True, @@ -160,7 +163,7 @@ class NotifyBoxcar(NotifyBase): super().__init__(**kwargs) # Initialize tag list - self.tags = list() + self._tags = list() # Initialize device_token list self.device_tokens = list() @@ -184,29 +187,27 @@ class NotifyBoxcar(NotifyBase): raise TypeError(msg) if not targets: - self.tags.append(DEFAULT_TAG) + self._tags.append(DEFAULT_TAG) targets = [] - elif isinstance(targets, str): - targets = [x for x in filter(bool, TAGS_LIST_DELIM.split( - targets, - ))] - # Validate targets and drop bad ones: - for target in targets: - if IS_TAG.match(target): + for target in parse_list(targets): + result = IS_TAG.match(target) + if result: # store valid tag/alias - self.tags.append(IS_TAG.match(target).group('name')) + self._tags.append(result.group('name')) + continue - elif IS_DEVICETOKEN.match(target): + result = IS_DEVICETOKEN.match(target) + if result: # store valid device self.device_tokens.append(target) + continue - else: - self.logger.warning( - 'Dropped invalid tag/alias/device_token ' - '({}) specified.'.format(target), - ) + self.logger.warning( + 'Dropped invalid tag/alias/device_token ' + '({}) specified.'.format(target), + ) # Track whether or not we want to send an image with our notification # or not. @@ -235,11 +236,10 @@ class NotifyBoxcar(NotifyBase): if title: payload['aps']['@title'] = title - if body: - payload['aps']['alert'] = body + payload['aps']['alert'] = body - if self.tags: - payload['tags'] = {'or': self.tags} + if self._tags: + payload['tags'] = {'or': self._tags} if self.device_tokens: payload['device_tokens'] = self.device_tokens @@ -341,10 +341,18 @@ class NotifyBoxcar(NotifyBase): self.secret, privacy, mode=PrivacyMode.Secret, safe=''), targets='/'.join([ NotifyBoxcar.quote(x, safe='') for x in chain( - self.tags, self.device_tokens) if x != DEFAULT_TAG]), + self._tags, self.device_tokens) if x != DEFAULT_TAG]), params=NotifyBoxcar.urlencode(params), ) + def __len__(self): + """ + Returns the number of targets associated with this notification + """ + targets = len(self._tags) + len(self.device_tokens) + # DEFAULT_TAG is set if no tokens/tags are otherwise set + return targets if targets > 0 else 1 + @staticmethod def parse_url(url): """ @@ -374,6 +382,16 @@ class NotifyBoxcar(NotifyBase): results['targets'] += \ NotifyBoxcar.parse_list(results['qsd'].get('to')) + # Access + if 'access' in results['qsd'] and results['qsd']['access']: + results['access'] = NotifyBoxcar.unquote( + results['qsd']['access'].strip()) + + # Secret + if 'secret' in results['qsd'] and results['qsd']['secret']: + results['secret'] = NotifyBoxcar.unquote( + results['qsd']['secret'].strip()) + # Include images with our message results['include_image'] = \ parse_bool(results['qsd'].get('image', True)) diff --git a/lib/apprise/plugins/NotifyBulkSMS.py b/lib/apprise/plugins/NotifyBulkSMS.py index 257c1def..cf82a87a 100644 --- a/lib/apprise/plugins/NotifyBulkSMS.py +++ b/lib/apprise/plugins/NotifyBulkSMS.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -# BSD 3-Clause License +# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2023, Chris Caron @@ -14,10 +14,6 @@ # 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 @@ -121,11 +117,13 @@ class NotifyBulkSMS(NotifyBase): 'user': { 'name': _('User Name'), 'type': 'string', + 'required': True, }, 'password': { 'name': _('Password'), 'type': 'string', 'private': True, + 'required': True, }, 'target_phone': { 'name': _('Target Phone No'), @@ -144,6 +142,7 @@ class NotifyBulkSMS(NotifyBase): 'targets': { 'name': _('Targets'), 'type': 'list:string', + 'required': True, }, }) @@ -414,6 +413,24 @@ class NotifyBulkSMS(NotifyBase): for x in self.groups])), params=NotifyBulkSMS.urlencode(params)) + def __len__(self): + """ + Returns the number of targets associated with this notification + """ + + # + # Factor batch into calculation + # + # Note: Groups always require a separate request (and can not be + # included in batch calculations) + batch_size = 1 if not self.batch else self.default_batch_size + targets = len(self.targets) + if batch_size > 1: + targets = int(targets / batch_size) + \ + (1 if targets % batch_size else 0) + + return targets + len(self.groups) + @staticmethod def parse_url(url): """ diff --git a/lib/apprise/plugins/NotifyBurstSMS.py b/lib/apprise/plugins/NotifyBurstSMS.py new file mode 100644 index 00000000..59219b3d --- /dev/null +++ b/lib/apprise/plugins/NotifyBurstSMS.py @@ -0,0 +1,460 @@ +# -*- coding: utf-8 -*- +# BSD 2-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. +# +# 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. + +# Sign-up with https://burstsms.com/ +# +# Define your API Secret here and acquire your API Key +# - https://can.transmitsms.com/profile +# +import requests + +from .NotifyBase import NotifyBase +from ..URLBase import PrivacyMode +from ..common import NotifyType +from ..utils import is_phone_no +from ..utils import parse_phone_no +from ..utils import parse_bool +from ..utils import validate_regex +from ..AppriseLocale import gettext_lazy as _ + + +class BurstSMSCountryCode: + # Australia + AU = 'au' + # New Zeland + NZ = 'nz' + # United Kingdom + UK = 'gb' + # United States + US = 'us' + + +BURST_SMS_COUNTRY_CODES = ( + BurstSMSCountryCode.AU, + BurstSMSCountryCode.NZ, + BurstSMSCountryCode.UK, + BurstSMSCountryCode.US, +) + + +class NotifyBurstSMS(NotifyBase): + """ + A wrapper for Burst SMS Notifications + """ + + # The default descriptive name associated with the Notification + service_name = 'Burst SMS' + + # The services URL + service_url = 'https://burstsms.com/' + + # The default protocol + secure_protocol = 'burstsms' + + # The maximum amount of SMS Messages that can reside within a single + # batch transfer based on: + # https://developer.transmitsms.com/#74911cf8-dec6-4319-a499-7f535a7fd08c + default_batch_size = 500 + + # A URL that takes you to the setup/help of the specific protocol + setup_url = 'https://github.com/caronc/apprise/wiki/Notify_burst_sms' + + # Burst SMS uses the http protocol with JSON requests + notify_url = 'https://api.transmitsms.com/send-sms.json' + + # The maximum length of the body + body_maxlen = 160 + + # A title can not be used for SMS Messages. Setting this to zero will + # cause any title (if defined) to get placed into the message body. + title_maxlen = 0 + + # Define object templates + templates = ( + '{schema}://{apikey}:{secret}@{sender_id}/{targets}', + ) + + # Define our template tokens + template_tokens = dict(NotifyBase.template_tokens, **{ + 'apikey': { + 'name': _('API Key'), + 'type': 'string', + 'required': True, + 'regex': (r'^[a-z0-9]+$', 'i'), + 'private': True, + }, + 'secret': { + 'name': _('API Secret'), + 'type': 'string', + 'private': True, + 'required': True, + 'regex': (r'^[a-z0-9]+$', 'i'), + }, + 'sender_id': { + 'name': _('Sender ID'), + 'type': 'string', + 'required': True, + 'map_to': 'source', + }, + 'target_phone': { + 'name': _('Target Phone No'), + 'type': 'string', + 'prefix': '+', + 'regex': (r'^[0-9\s)(+-]+$', 'i'), + 'map_to': 'targets', + }, + 'targets': { + 'name': _('Targets'), + 'type': 'list:string', + 'required': True, + }, + }) + + # Define our template arguments + template_args = dict(NotifyBase.template_args, **{ + 'to': { + 'alias_of': 'targets', + }, + 'from': { + 'alias_of': 'sender_id', + }, + 'key': { + 'alias_of': 'apikey', + }, + 'secret': { + 'alias_of': 'secret', + }, + 'country': { + 'name': _('Country'), + 'type': 'choice:string', + 'values': BURST_SMS_COUNTRY_CODES, + 'default': BurstSMSCountryCode.US, + }, + # Validity + # Expire a message send if it is undeliverable (defined in minutes) + # If set to Zero (0); this is the default and sets the max validity + # period + 'validity': { + 'name': _('validity'), + 'type': 'int', + 'default': 0 + }, + 'batch': { + 'name': _('Batch Mode'), + 'type': 'bool', + 'default': False, + }, + }) + + def __init__(self, apikey, secret, source, targets=None, country=None, + validity=None, batch=None, **kwargs): + """ + Initialize Burst SMS Object + """ + super().__init__(**kwargs) + + # API Key (associated with project) + self.apikey = validate_regex( + apikey, *self.template_tokens['apikey']['regex']) + if not self.apikey: + msg = 'An invalid Burst SMS API Key ' \ + '({}) was specified.'.format(apikey) + self.logger.warning(msg) + raise TypeError(msg) + + # API Secret (associated with project) + self.secret = validate_regex( + secret, *self.template_tokens['secret']['regex']) + if not self.secret: + msg = 'An invalid Burst SMS API Secret ' \ + '({}) was specified.'.format(secret) + self.logger.warning(msg) + raise TypeError(msg) + + if not country: + self.country = self.template_args['country']['default'] + + else: + self.country = country.lower().strip() + if country not in BURST_SMS_COUNTRY_CODES: + msg = 'An invalid Burst SMS country ' \ + '({}) was specified.'.format(country) + self.logger.warning(msg) + raise TypeError(msg) + + # Set our Validity + self.validity = self.template_args['validity']['default'] + if validity: + try: + self.validity = int(validity) + + except (ValueError, TypeError): + msg = 'The Burst SMS Validity specified ({}) is invalid.'\ + .format(validity) + self.logger.warning(msg) + raise TypeError(msg) + + # Prepare Batch Mode Flag + self.batch = self.template_args['batch']['default'] \ + if batch is None else batch + + # The Sender ID + self.source = validate_regex(source) + if not self.source: + msg = 'The Account Sender ID specified ' \ + '({}) is invalid.'.format(source) + self.logger.warning(msg) + raise TypeError(msg) + + # Parse our targets + self.targets = list() + + for target in parse_phone_no(targets): + # Validate targets and drop bad ones: + result = is_phone_no(target) + if not result: + self.logger.warning( + 'Dropped invalid phone # ' + '({}) specified.'.format(target), + ) + continue + + # store valid phone number + self.targets.append(result['full']) + + return + + def send(self, body, title='', notify_type=NotifyType.INFO, **kwargs): + """ + Perform Burst SMS Notification + """ + + if not self.targets: + self.logger.warning( + 'There are no valid Burst SMS targets to notify.') + return False + + # error tracking (used for function return) + has_error = False + + # Prepare our headers + headers = { + 'User-Agent': self.app_id, + 'Accept': 'application/json', + } + + # Prepare our authentication + auth = (self.apikey, self.secret) + + # Prepare our payload + payload = { + 'countrycode': self.country, + 'message': body, + + # Sender ID + 'from': self.source, + + # The to gets populated in the loop below + 'to': None, + } + + # Send in batches if identified to do so + batch_size = 1 if not self.batch else self.default_batch_size + + # Create a copy of the targets list + targets = list(self.targets) + + for index in range(0, len(targets), batch_size): + + # Prepare our user + payload['to'] = ','.join(self.targets[index:index + batch_size]) + + # Some Debug Logging + self.logger.debug('Burst SMS POST URL: {} (cert_verify={})'.format( + self.notify_url, self.verify_certificate)) + self.logger.debug('Burst SMS Payload: {}' .format(payload)) + + # Always call throttle before any remote server i/o is made + self.throttle() + + try: + r = requests.post( + self.notify_url, + data=payload, + headers=headers, + auth=auth, + verify=self.verify_certificate, + timeout=self.request_timeout, + ) + + if r.status_code != requests.codes.ok: + # We had a problem + status_str = \ + NotifyBurstSMS.http_response_code_lookup( + r.status_code) + + self.logger.warning( + 'Failed to send Burst SMS notification to {} ' + 'target(s): {}{}error={}.'.format( + len(self.targets[index:index + batch_size]), + status_str, + ', ' if status_str else '', + r.status_code)) + + self.logger.debug( + 'Response Details:\r\n{}'.format(r.content)) + + # Mark our failure + has_error = True + continue + + else: + self.logger.info( + 'Sent Burst SMS notification to %d target(s).' % + len(self.targets[index:index + batch_size])) + + except requests.RequestException as e: + self.logger.warning( + 'A Connection error occurred sending Burst SMS ' + 'notification to %d target(s).' % + len(self.targets[index:index + batch_size])) + self.logger.debug('Socket Exception: %s' % str(e)) + + # Mark our failure + has_error = True + continue + + return not has_error + + def url(self, privacy=False, *args, **kwargs): + """ + Returns the URL built dynamically based on specified arguments. + """ + + # Define any URL parameters + params = { + 'country': self.country, + 'batch': 'yes' if self.batch else 'no', + } + + if self.validity: + params['validity'] = str(self.validity) + + # Extend our parameters + params.update(self.url_parameters(privacy=privacy, *args, **kwargs)) + + return '{schema}://{key}:{secret}@{source}/{targets}/?{params}'.format( + schema=self.secure_protocol, + key=self.pprint(self.apikey, privacy, safe=''), + secret=self.pprint( + self.secret, privacy, mode=PrivacyMode.Secret, safe=''), + source=NotifyBurstSMS.quote(self.source, safe=''), + targets='/'.join( + [NotifyBurstSMS.quote(x, safe='') for x in self.targets]), + params=NotifyBurstSMS.urlencode(params)) + + def __len__(self): + """ + Returns the number of targets associated with this notification + """ + # + # Factor batch into calculation + # + batch_size = 1 if not self.batch else self.default_batch_size + targets = len(self.targets) + if batch_size > 1: + targets = int(targets / batch_size) + \ + (1 if targets % batch_size else 0) + + 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 + + # The hostname is our source (Sender ID) + results['source'] = NotifyBurstSMS.unquote(results['host']) + + # Get any remaining targets + results['targets'] = NotifyBurstSMS.split_path(results['fullpath']) + + # Get our account_side and auth_token from the user/pass config + results['apikey'] = NotifyBurstSMS.unquote(results['user']) + results['secret'] = NotifyBurstSMS.unquote(results['password']) + + # API Key + if 'key' in results['qsd'] and len(results['qsd']['key']): + # Extract the API Key from an argument + results['apikey'] = \ + NotifyBurstSMS.unquote(results['qsd']['key']) + + # API Secret + if 'secret' in results['qsd'] and len(results['qsd']['secret']): + # Extract the API Secret from an argument + results['secret'] = \ + NotifyBurstSMS.unquote(results['qsd']['secret']) + + # Support the 'from' and 'source' variable so that we can support + # targets this way too. + # The 'from' makes it easier to use yaml configuration + if 'from' in results['qsd'] and len(results['qsd']['from']): + results['source'] = \ + NotifyBurstSMS.unquote(results['qsd']['from']) + if 'source' in results['qsd'] and len(results['qsd']['source']): + results['source'] = \ + NotifyBurstSMS.unquote(results['qsd']['source']) + + # Support country + if 'country' in results['qsd'] and len(results['qsd']['country']): + results['country'] = \ + NotifyBurstSMS.unquote(results['qsd']['country']) + + # Support validity value + if 'validity' in results['qsd'] and len(results['qsd']['validity']): + results['validity'] = \ + NotifyBurstSMS.unquote(results['qsd']['validity']) + + # Get Batch Mode Flag + if 'batch' in results['qsd'] and len(results['qsd']['batch']): + results['batch'] = parse_bool(results['qsd']['batch']) + + # 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'] += \ + NotifyBurstSMS.parse_phone_no(results['qsd']['to']) + + return results diff --git a/lib/apprise/plugins/NotifyClickSend.py b/lib/apprise/plugins/NotifyClickSend.py index c93201be..670e74e8 100644 --- a/lib/apprise/plugins/NotifyClickSend.py +++ b/lib/apprise/plugins/NotifyClickSend.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -# BSD 3-Clause License +# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2023, Chris Caron @@ -14,10 +14,6 @@ # 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 @@ -288,6 +284,21 @@ class NotifyClickSend(NotifyBase): params=NotifyClickSend.urlencode(params), ) + def __len__(self): + """ + Returns the number of targets associated with this notification + """ + # + # Factor batch into calculation + # + batch_size = 1 if not self.batch else self.default_batch_size + targets = len(self.targets) + if batch_size > 1: + targets = int(targets / batch_size) + \ + (1 if targets % batch_size else 0) + + return targets + @staticmethod def parse_url(url): """ diff --git a/lib/apprise/plugins/NotifyD7Networks.py b/lib/apprise/plugins/NotifyD7Networks.py index 7b17f848..3e7787da 100644 --- a/lib/apprise/plugins/NotifyD7Networks.py +++ b/lib/apprise/plugins/NotifyD7Networks.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -# BSD 3-Clause License +# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2023, Chris Caron @@ -14,10 +14,6 @@ # 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 @@ -114,6 +110,7 @@ class NotifyD7Networks(NotifyBase): 'targets': { 'name': _('Targets'), 'type': 'list:string', + 'required': True, }, }) @@ -357,6 +354,15 @@ class NotifyD7Networks(NotifyBase): [NotifyD7Networks.quote(x, safe='') for x in self.targets]), params=NotifyD7Networks.urlencode(params)) + def __len__(self): + """ + Returns the number of targets associated with this notification + """ + # + # Factor batch into calculation + # + return len(self.targets) if not self.batch else 1 + @staticmethod def parse_url(url): """ diff --git a/lib/apprise/plugins/NotifyDBus.py b/lib/apprise/plugins/NotifyDBus.py index 336dfac4..46f8b9d0 100644 --- a/lib/apprise/plugins/NotifyDBus.py +++ b/lib/apprise/plugins/NotifyDBus.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -# BSD 3-Clause License +# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2023, Chris Caron @@ -14,10 +14,6 @@ # 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 diff --git a/lib/apprise/plugins/NotifyDapnet.py b/lib/apprise/plugins/NotifyDapnet.py index bf1ff333..5848b688 100644 --- a/lib/apprise/plugins/NotifyDapnet.py +++ b/lib/apprise/plugins/NotifyDapnet.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -# BSD 3-Clause License +# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2023, Chris Caron @@ -14,10 +14,6 @@ # 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 @@ -350,6 +346,21 @@ class NotifyDapnet(NotifyBase): params=NotifyDapnet.urlencode(params), ) + def __len__(self): + """ + Returns the number of targets associated with this notification + """ + # + # Factor batch into calculation + # + batch_size = 1 if not self.batch else self.default_batch_size + targets = len(self.targets) + if batch_size > 1: + targets = int(targets / batch_size) + \ + (1 if targets % batch_size else 0) + + return targets + @staticmethod def parse_url(url): """ diff --git a/lib/apprise/plugins/NotifyDingTalk.py b/lib/apprise/plugins/NotifyDingTalk.py index 474d4c88..91bfcd6f 100644 --- a/lib/apprise/plugins/NotifyDingTalk.py +++ b/lib/apprise/plugins/NotifyDingTalk.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -# BSD 3-Clause License +# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2023, Chris Caron @@ -14,10 +14,6 @@ # 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 @@ -103,13 +99,18 @@ class NotifyDingTalk(NotifyBase): 'regex': (r'^[a-z0-9]+$', 'i'), }, 'secret': { - 'name': _('Token'), + 'name': _('Secret'), 'type': 'string', 'private': True, 'regex': (r'^[a-z0-9]+$', 'i'), }, - 'targets': { + 'target_phone_no': { 'name': _('Target Phone No'), + 'type': 'string', + 'map_to': 'targets', + }, + 'targets': { + 'name': _('Targets'), 'type': 'list:string', }, }) @@ -309,6 +310,13 @@ class NotifyDingTalk(NotifyBase): [NotifyDingTalk.quote(x, safe='') for x in self.targets]), args=NotifyDingTalk.urlencode(args)) + def __len__(self): + """ + Returns the number of targets associated with this notification + """ + targets = len(self.targets) + return targets if targets > 0 else 1 + @staticmethod def parse_url(url): """ diff --git a/lib/apprise/plugins/NotifyDiscord.py b/lib/apprise/plugins/NotifyDiscord.py index 78cd3265..f87b6694 100644 --- a/lib/apprise/plugins/NotifyDiscord.py +++ b/lib/apprise/plugins/NotifyDiscord.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -# BSD 3-Clause License +# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2023, Chris Caron @@ -14,10 +14,6 @@ # 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 @@ -50,6 +46,9 @@ import re import requests from json import dumps +from datetime import timedelta +from datetime import datetime +from datetime import timezone from .NotifyBase import NotifyBase from ..common import NotifyImageSize @@ -81,9 +80,23 @@ class NotifyDiscord(NotifyBase): # Discord Webhook notify_url = 'https://discord.com/api/webhooks' + # Support attachments + attachment_support = True + # Allows the user to specify the NotifyImageSize object image_size = NotifyImageSize.XY_256 + # Discord is kind enough to return how many more requests we're allowed to + # continue to make within it's header response as: + # X-RateLimit-Reset: The epoc time (in seconds) we can expect our + # rate-limit to be reset. + # X-RateLimit-Remaining: an integer identifying how many requests we're + # still allow to make. + request_rate_per_sec = 0 + + # Taken right from google.auth.helpers: + clock_skew = timedelta(seconds=10) + # The maximum allowable characters allowed in the body per message body_maxlen = 2000 @@ -135,6 +148,13 @@ class NotifyDiscord(NotifyBase): 'name': _('Avatar URL'), 'type': 'string', }, + 'href': { + 'name': _('URL'), + 'type': 'string', + }, + 'url': { + 'alias_of': 'href', + }, # Send a message to the specified thread within a webhook's channel. # The thread will automatically be unarchived. 'thread': { @@ -166,7 +186,8 @@ class NotifyDiscord(NotifyBase): def __init__(self, webhook_id, webhook_token, tts=False, avatar=True, footer=False, footer_logo=True, include_image=False, - fields=True, avatar_url=None, thread=None, **kwargs): + fields=True, avatar_url=None, href=None, thread=None, + **kwargs): """ Initialize Discord Object @@ -215,6 +236,15 @@ class NotifyDiscord(NotifyBase): # dynamically generated avatar url images self.avatar_url = avatar_url + # A URL to have the title link to + self.href = href + + # For Tracking Purposes + self.ratelimit_reset = datetime.now(timezone.utc).replace(tzinfo=None) + + # Default to 1.0 + self.ratelimit_remaining = 1.0 + return def send(self, body, title='', notify_type=NotifyType.INFO, attach=None, @@ -235,64 +265,6 @@ class NotifyDiscord(NotifyBase): # Acquire image_url image_url = self.image_url(notify_type) - # our fields variable - fields = [] - - if self.notify_format == NotifyFormat.MARKDOWN: - # Use embeds for payload - payload['embeds'] = [{ - 'author': { - 'name': self.app_id, - 'url': self.app_url, - }, - 'title': title, - 'description': body, - - # Our color associated with our notification - 'color': self.color(notify_type, int), - }] - - if self.footer: - # Acquire logo URL - logo_url = self.image_url(notify_type, logo=True) - - # Set Footer text to our app description - payload['embeds'][0]['footer'] = { - 'text': self.app_desc, - } - - if self.footer_logo and logo_url: - payload['embeds'][0]['footer']['icon_url'] = logo_url - - if self.include_image and image_url: - payload['embeds'][0]['thumbnail'] = { - 'url': image_url, - 'height': 256, - 'width': 256, - } - - if self.fields: - # Break titles out so that we can sort them in embeds - description, fields = self.extract_markdown_sections(body) - - # Swap first entry for description - payload['embeds'][0]['description'] = description - if fields: - # Apply our additional parsing for a better presentation - payload['embeds'][0]['fields'] = \ - fields[:self.discord_max_fields] - - # Remove entry from head of fields - fields = fields[self.discord_max_fields:] - - else: - # not markdown - payload['content'] = \ - body if not title else "{}\r\n{}".format(title, body) - - if self.thread_id: - payload['thread_id'] = self.thread_id - if self.avatar and (image_url or self.avatar_url): payload['avatar_url'] = \ self.avatar_url if self.avatar_url else image_url @@ -301,21 +273,84 @@ class NotifyDiscord(NotifyBase): # Optionally override the default username of the webhook payload['username'] = self.user - if not self._send(payload): - # We failed to post our message - return False + # Associate our thread_id with our message + params = {'thread_id': self.thread_id} if self.thread_id else None - # Process any remaining fields IF set - if fields: - payload['embeds'][0]['description'] = '' - for i in range(0, len(fields), self.discord_max_fields): - payload['embeds'][0]['fields'] = \ - fields[i:i + self.discord_max_fields] - if not self._send(payload): - # We failed to post our message - return False + if body: + # our fields variable + fields = [] - if attach: + if self.notify_format == NotifyFormat.MARKDOWN: + # Use embeds for payload + payload['embeds'] = [{ + 'author': { + 'name': self.app_id, + 'url': self.app_url, + }, + 'title': title, + 'description': body, + + # Our color associated with our notification + 'color': self.color(notify_type, int), + }] + + if self.href: + payload['embeds'][0]['url'] = self.href + + if self.footer: + # Acquire logo URL + logo_url = self.image_url(notify_type, logo=True) + + # Set Footer text to our app description + payload['embeds'][0]['footer'] = { + 'text': self.app_desc, + } + + if self.footer_logo and logo_url: + payload['embeds'][0]['footer']['icon_url'] = logo_url + + if self.include_image and image_url: + payload['embeds'][0]['thumbnail'] = { + 'url': image_url, + 'height': 256, + 'width': 256, + } + + if self.fields: + # Break titles out so that we can sort them in embeds + description, fields = self.extract_markdown_sections(body) + + # Swap first entry for description + payload['embeds'][0]['description'] = description + if fields: + # Apply our additional parsing for a better + # presentation + payload['embeds'][0]['fields'] = \ + fields[:self.discord_max_fields] + + # Remove entry from head of fields + fields = fields[self.discord_max_fields:] + + else: + # not markdown + payload['content'] = \ + body if not title else "{}\r\n{}".format(title, body) + + if not self._send(payload, params=params): + # We failed to post our message + return False + + # Process any remaining fields IF set + if fields: + payload['embeds'][0]['description'] = '' + for i in range(0, len(fields), self.discord_max_fields): + payload['embeds'][0]['fields'] = \ + fields[i:i + self.discord_max_fields] + if not self._send(payload): + # We failed to post our message + return False + + if attach and self.attachment_support: # Update our payload; the idea is to preserve it's other detected # and assigned values for re-use here too payload.update({ @@ -338,14 +373,15 @@ class NotifyDiscord(NotifyBase): for attachment in attach: self.logger.info( 'Posting Discord Attachment {}'.format(attachment.name)) - if not self._send(payload, attach=attachment): + if not self._send(payload, params=params, attach=attachment): # We failed to post our message return False # Otherwise return return True - def _send(self, payload, attach=None, **kwargs): + def _send(self, payload, attach=None, params=None, rate_limit=1, + **kwargs): """ Wrapper to the requests (post) object """ @@ -367,8 +403,25 @@ class NotifyDiscord(NotifyBase): )) self.logger.debug('Discord Payload: %s' % str(payload)) - # Always call throttle before any remote server i/o is made - self.throttle() + # By default set wait to None + wait = None + + if self.ratelimit_remaining <= 0.0: + # Determine how long we should wait for or if we should wait at + # all. This isn't fool-proof because we can't be sure the client + # time (calling this script) is completely synced up with the + # Discord server. One would hope we're on NTP and our clocks are + # the same allowing this to role smoothly: + + now = datetime.now(timezone.utc).replace(tzinfo=None) + if now < self.ratelimit_reset: + # We need to throttle for the difference in seconds + wait = abs( + (self.ratelimit_reset - now + self.clock_skew) + .total_seconds()) + + # Always call throttle before any remote server i/o is made; + self.throttle(wait=wait) # Perform some simple error checking if isinstance(attach, AttachBase): @@ -396,12 +449,29 @@ class NotifyDiscord(NotifyBase): r = requests.post( notify_url, + params=params, data=payload if files else dumps(payload), headers=headers, files=files, verify=self.verify_certificate, timeout=self.request_timeout, ) + + # Handle rate limiting (if specified) + try: + # Store our rate limiting (if provided) + self.ratelimit_remaining = \ + float(r.headers.get( + 'X-RateLimit-Remaining')) + self.ratelimit_reset = datetime.fromtimestamp( + int(r.headers.get('X-RateLimit-Reset')), + timezone.utc).replace(tzinfo=None) + + except (TypeError, ValueError): + # This is returned if we could not retrieve this + # information gracefully accept this state and move on + pass + if r.status_code not in ( requests.codes.ok, requests.codes.no_content): @@ -409,6 +479,20 @@ class NotifyDiscord(NotifyBase): status_str = \ NotifyBase.http_response_code_lookup(r.status_code) + if r.status_code == requests.codes.too_many_requests \ + and rate_limit > 0: + + # handle rate limiting + self.logger.warning( + 'Discord rate limiting in effect; ' + 'blocking for %.2f second(s)', + self.ratelimit_remaining) + + # Try one more time before failing + return self._send( + payload=payload, attach=attach, params=params, + rate_limit=rate_limit - 1, **kwargs) + self.logger.warning( 'Failed to send {}to Discord notification: ' '{}{}error={}.'.format( @@ -466,6 +550,9 @@ class NotifyDiscord(NotifyBase): if self.avatar_url: params['avatar_url'] = self.avatar_url + if self.href: + params['href'] = self.href + if self.thread_id: params['thread'] = self.thread_id @@ -537,10 +624,23 @@ class NotifyDiscord(NotifyBase): results['avatar_url'] = \ NotifyDiscord.unquote(results['qsd']['avatar_url']) + # Extract url if it was specified + if 'href' in results['qsd']: + results['href'] = \ + NotifyDiscord.unquote(results['qsd']['href']) + + elif 'url' in results['qsd']: + results['href'] = \ + NotifyDiscord.unquote(results['qsd']['url']) + # Markdown is implied + results['format'] = NotifyFormat.MARKDOWN + # Extract thread id if it was specified if 'thread' in results['qsd']: results['thread'] = \ NotifyDiscord.unquote(results['qsd']['thread']) + # Markdown is implied + results['format'] = NotifyFormat.MARKDOWN return results diff --git a/lib/apprise/plugins/NotifyEmail.py b/lib/apprise/plugins/NotifyEmail.py index 8698c113..db70c8ef 100644 --- a/lib/apprise/plugins/NotifyEmail.py +++ b/lib/apprise/plugins/NotifyEmail.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -# BSD 3-Clause License +# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2023, Chris Caron @@ -14,10 +14,6 @@ # 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 @@ -43,6 +39,7 @@ from email import charset from socket import error as SocketError from datetime import datetime +from datetime import timezone from .NotifyBase import NotifyBase from ..URLBase import PrivacyMode @@ -340,6 +337,9 @@ class NotifyEmail(NotifyBase): # A URL that takes you to the setup/help of the specific protocol setup_url = 'https://github.com/caronc/apprise/wiki/Notify_email' + # Support attachments + attachment_support = True + # Default Notify Format notify_format = NotifyFormat.HTML @@ -384,8 +384,13 @@ class NotifyEmail(NotifyBase): 'min': 1, 'max': 65535, }, + 'target_email': { + 'name': _('Target Email'), + 'type': 'string', + 'map_to': 'targets', + }, 'targets': { - 'name': _('Target Emails'), + 'name': _('Targets'), 'type': 'list:string', }, }) @@ -764,7 +769,7 @@ class NotifyEmail(NotifyBase): else: base = MIMEText(body, 'plain', 'utf-8') - if attach: + if attach and self.attachment_support: mixed = MIMEMultipart("mixed") mixed.attach(base) # Now store our attachments @@ -805,7 +810,8 @@ class NotifyEmail(NotifyBase): base['To'] = formataddr((to_name, to_addr), charset='utf-8') base['Message-ID'] = make_msgid(domain=self.smtp_host) base['Date'] = \ - datetime.utcnow().strftime("%a, %d %b %Y %H:%M:%S +0000") + datetime.now(timezone.utc)\ + .strftime("%a, %d %b %Y %H:%M:%S +0000") base['X-Application'] = self.app_id if cc: @@ -999,6 +1005,13 @@ class NotifyEmail(NotifyBase): params=NotifyEmail.urlencode(params), ) + def __len__(self): + """ + Returns the number of targets associated with this notification + """ + targets = len(self.targets) + return targets if targets > 0 else 1 + @staticmethod def parse_url(url): """ @@ -1023,6 +1036,10 @@ class NotifyEmail(NotifyBase): # add one to ourselves results['targets'] = NotifyEmail.split_path(results['fullpath']) + # Attempt to detect 'to' email address + if 'to' in results['qsd'] and len(results['qsd']['to']): + results['targets'].append(results['qsd']['to']) + # Attempt to detect 'from' email address if 'from' in results['qsd'] and len(results['qsd']['from']): from_addr = NotifyEmail.unquote(results['qsd']['from']) @@ -1041,10 +1058,6 @@ class NotifyEmail(NotifyBase): # Extract from name to associate with from address from_addr = NotifyEmail.unquote(results['qsd']['name']) - # Attempt to detect 'to' email address - if 'to' in results['qsd'] and len(results['qsd']['to']): - results['targets'].append(results['qsd']['to']) - # Store SMTP Host if specified if 'smtp' in results['qsd'] and len(results['qsd']['smtp']): # Extract the smtp server diff --git a/lib/apprise/plugins/NotifyEmby.py b/lib/apprise/plugins/NotifyEmby.py index 23d4c611..99f3a9ab 100644 --- a/lib/apprise/plugins/NotifyEmby.py +++ b/lib/apprise/plugins/NotifyEmby.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -# BSD 3-Clause License +# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2023, Chris Caron @@ -14,10 +14,6 @@ # 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 diff --git a/lib/apprise/plugins/NotifyEnigma2.py b/lib/apprise/plugins/NotifyEnigma2.py index 10d58179..05472646 100644 --- a/lib/apprise/plugins/NotifyEnigma2.py +++ b/lib/apprise/plugins/NotifyEnigma2.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -# BSD 3-Clause License +# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2023, Chris Caron @@ -14,10 +14,6 @@ # 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 diff --git a/lib/apprise/plugins/NotifyFCM/__init__.py b/lib/apprise/plugins/NotifyFCM/__init__.py index 098f9ad0..57b03499 100644 --- a/lib/apprise/plugins/NotifyFCM/__init__.py +++ b/lib/apprise/plugins/NotifyFCM/__init__.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -# BSD 3-Clause License +# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2023, Chris Caron @@ -14,10 +14,6 @@ # 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 @@ -157,7 +153,6 @@ class NotifyFCM(NotifyBase): 'project': { 'name': _('Project ID'), 'type': 'string', - 'required': True, }, 'target_device': { 'name': _('Target Device'), @@ -173,6 +168,7 @@ class NotifyFCM(NotifyBase): 'targets': { 'name': _('Targets'), 'type': 'list:string', + 'required': True, }, }) @@ -555,6 +551,12 @@ class NotifyFCM(NotifyBase): params=NotifyFCM.urlencode(params), ) + def __len__(self): + """ + Returns the number of targets associated with this notification + """ + return len(self.targets) + @staticmethod def parse_url(url): """ diff --git a/lib/apprise/plugins/NotifyFCM/color.py b/lib/apprise/plugins/NotifyFCM/color.py index 46d0f2a7..69474a30 100644 --- a/lib/apprise/plugins/NotifyFCM/color.py +++ b/lib/apprise/plugins/NotifyFCM/color.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -# BSD 3-Clause License +# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2023, Chris Caron @@ -14,10 +14,6 @@ # 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 diff --git a/lib/apprise/plugins/NotifyFCM/common.py b/lib/apprise/plugins/NotifyFCM/common.py index 0ec10eec..af71f881 100644 --- a/lib/apprise/plugins/NotifyFCM/common.py +++ b/lib/apprise/plugins/NotifyFCM/common.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -# BSD 3-Clause License +# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2023, Chris Caron @@ -14,10 +14,6 @@ # 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 diff --git a/lib/apprise/plugins/NotifyFCM/oauth.py b/lib/apprise/plugins/NotifyFCM/oauth.py index a76bc698..f0961039 100644 --- a/lib/apprise/plugins/NotifyFCM/oauth.py +++ b/lib/apprise/plugins/NotifyFCM/oauth.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -# BSD 3-Clause License +# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2023, Chris Caron @@ -14,10 +14,6 @@ # 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 @@ -47,6 +43,7 @@ from cryptography.hazmat.primitives import asymmetric from cryptography.exceptions import UnsupportedAlgorithm from datetime import datetime from datetime import timedelta +from datetime import timezone from json.decoder import JSONDecodeError from urllib.parse import urlencode as _urlencode @@ -106,7 +103,7 @@ class GoogleOAuth: # Our keys we build using the provided content self.__refresh_token = None self.__access_token = None - self.__access_token_expiry = datetime.utcnow() + self.__access_token_expiry = datetime.now(timezone.utc) def load(self, path): """ @@ -117,7 +114,7 @@ class GoogleOAuth: self.content = None self.private_key = None self.__access_token = None - self.__access_token_expiry = datetime.utcnow() + self.__access_token_expiry = datetime.now(timezone.utc) try: with open(path, mode="r", encoding=self.encoding) as fp: @@ -199,7 +196,7 @@ class GoogleOAuth: 'token with.') return None - if self.__access_token_expiry > datetime.utcnow(): + if self.__access_token_expiry > datetime.now(timezone.utc): # Return our no-expired key return self.__access_token @@ -209,7 +206,7 @@ class GoogleOAuth: key_identifier = self.content.get('private_key_id') # Generate our Assertion - now = datetime.utcnow() + now = datetime.now(timezone.utc) expiry = now + self.access_token_lifetime_sec payload = { @@ -301,7 +298,7 @@ class GoogleOAuth: if 'expires_in' in response: delta = timedelta(seconds=int(response['expires_in'])) self.__access_token_expiry = \ - delta + datetime.utcnow() - self.clock_skew + delta + datetime.now(timezone.utc) - self.clock_skew else: # Allow some grace before we expire diff --git a/lib/apprise/plugins/NotifyFCM/priority.py b/lib/apprise/plugins/NotifyFCM/priority.py index 81976cb6..966a0e14 100644 --- a/lib/apprise/plugins/NotifyFCM/priority.py +++ b/lib/apprise/plugins/NotifyFCM/priority.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -# BSD 3-Clause License +# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2023, Chris Caron @@ -14,10 +14,6 @@ # 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 diff --git a/lib/apprise/plugins/NotifyFaast.py b/lib/apprise/plugins/NotifyFaast.py index 3e55e120..be3eff28 100644 --- a/lib/apprise/plugins/NotifyFaast.py +++ b/lib/apprise/plugins/NotifyFaast.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -# BSD 3-Clause License +# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2023, Chris Caron @@ -14,10 +14,6 @@ # 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 diff --git a/lib/apprise/plugins/NotifyFlock.py b/lib/apprise/plugins/NotifyFlock.py index 4f34b662..71a15da5 100644 --- a/lib/apprise/plugins/NotifyFlock.py +++ b/lib/apprise/plugins/NotifyFlock.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -# BSD 3-Clause License +# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2023, Chris Caron @@ -14,10 +14,6 @@ # 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 @@ -97,8 +93,8 @@ class NotifyFlock(NotifyBase): # Define object templates templates = ( '{schema}://{token}', - '{schema}://{user}@{token}', - '{schema}://{user}@{token}/{targets}', + '{schema}://{botname}@{token}', + '{schema}://{botname}@{token}/{targets}', '{schema}://{token}/{targets}', ) @@ -111,9 +107,10 @@ class NotifyFlock(NotifyBase): 'private': True, 'required': True, }, - 'user': { + 'botname': { 'name': _('Bot Name'), 'type': 'string', + 'map_to': 'user', }, 'to_user': { 'name': _('To User ID'), @@ -334,6 +331,13 @@ class NotifyFlock(NotifyBase): params=NotifyFlock.urlencode(params), ) + def __len__(self): + """ + Returns the number of targets associated with this notification + """ + targets = len(self.targets) + return targets if targets > 0 else 1 + @staticmethod def parse_url(url): """ diff --git a/lib/apprise/plugins/NotifyForm.py b/lib/apprise/plugins/NotifyForm.py index b14ae5ef..066f299b 100644 --- a/lib/apprise/plugins/NotifyForm.py +++ b/lib/apprise/plugins/NotifyForm.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -# BSD 3-Clause License +# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2023, Chris Caron @@ -14,10 +14,6 @@ # 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 @@ -40,6 +36,16 @@ from ..common import NotifyType from ..AppriseLocale import gettext_lazy as _ +class FORMPayloadField: + """ + Identifies the fields available in the FORM Payload + """ + VERSION = 'version' + TITLE = 'title' + MESSAGE = 'message' + MESSAGETYPE = 'type' + + # Defines the method to send the notification METHODS = ( 'POST', @@ -89,6 +95,9 @@ class NotifyForm(NotifyBase): # A URL that takes you to the setup/help of the specific protocol setup_url = 'https://github.com/caronc/apprise/wiki/Notify_Custom_Form' + # Support attachments + attachment_support = True + # Allows the user to specify the NotifyImageSize object image_size = NotifyImageSize.XY_128 @@ -96,6 +105,12 @@ class NotifyForm(NotifyBase): # local anyway request_rate_per_sec = 0 + # Define the FORM version to place in all payloads + # Version: Major.Minor, Major is only updated if the entire schema is + # changed. If just adding new items (or removing old ones, only increment + # the Minor! + form_version = '1.0' + # Define object templates templates = ( '{schema}://{host}', @@ -218,6 +233,18 @@ class NotifyForm(NotifyBase): self.attach_as += self.attach_as_count self.attach_multi_support = True + # A payload map allows users to over-ride the default mapping if + # they're detected with the :overide=value. Normally this would + # create a new key and assign it the value specified. However + # if the key you specify is actually an internally mapped one, + # then a re-mapping takes place using the value + self.payload_map = { + FORMPayloadField.VERSION: FORMPayloadField.VERSION, + FORMPayloadField.TITLE: FORMPayloadField.TITLE, + FORMPayloadField.MESSAGE: FORMPayloadField.MESSAGE, + FORMPayloadField.MESSAGETYPE: FORMPayloadField.MESSAGETYPE, + } + self.params = {} if params: # Store our extra headers @@ -228,10 +255,20 @@ class NotifyForm(NotifyBase): # Store our extra headers self.headers.update(headers) + self.payload_overrides = {} self.payload_extras = {} if payload: # Store our extra payload entries self.payload_extras.update(payload) + for key in list(self.payload_extras.keys()): + # Any values set in the payload to alter a system related one + # alters the system key. Hence :message=msg maps the 'message' + # variable that otherwise already contains the payload to be + # 'msg' instead (containing the payload) + if key in self.payload_map: + self.payload_map[key] = self.payload_extras[key] + self.payload_overrides[key] = self.payload_extras[key] + del self.payload_extras[key] return @@ -257,6 +294,8 @@ class NotifyForm(NotifyBase): # Append our payload extra's into our parameters params.update( {':{}'.format(k): v for k, v in self.payload_extras.items()}) + params.update( + {':{}'.format(k): v for k, v in self.payload_overrides.items()}) if self.attach_as != self.attach_as_default: # Provide Attach-As extension details @@ -305,7 +344,7 @@ class NotifyForm(NotifyBase): # Track our potential attachments files = [] - if attach: + if attach and self.attachment_support: for no, attachment in enumerate(attach, start=1): # Perform some simple error checking if not attachment: @@ -337,15 +376,18 @@ class NotifyForm(NotifyBase): 'form:// Multi-Attachment Support not enabled') # prepare Form Object - payload = { - # Version: Major.Minor, Major is only updated if the entire - # schema is changed. If just adding new items (or removing - # old ones, only increment the Minor! - 'version': '1.0', - 'title': title, - 'message': body, - 'type': notify_type, - } + payload = {} + + for key, value in ( + (FORMPayloadField.VERSION, self.form_version), + (FORMPayloadField.TITLE, title), + (FORMPayloadField.MESSAGE, body), + (FORMPayloadField.MESSAGETYPE, notify_type)): + + if not self.payload_map[key]: + # Do not store element in payload response + continue + payload[self.payload_map[key]] = value # Apply any/all payload over-rides defined payload.update(self.payload_extras) diff --git a/lib/apprise/plugins/NotifyGitter.py b/lib/apprise/plugins/NotifyGitter.py deleted file mode 100644 index 48d14c7c..00000000 --- a/lib/apprise/plugins/NotifyGitter.py +++ /dev/null @@ -1,419 +0,0 @@ -# -*- 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. - -# Once you visit: https://developer.gitter.im/apps you'll get a personal -# access token that will look something like this: -# b5647881d563fm846dfbb2c27d1fe8f669b8f026 - -# Don't worry about generating an app; this token is all you need to form -# you're URL with. The syntax is as follows: -# gitter://{token}/{channel} - -# Hence a URL might look like the following: -# gitter://b5647881d563fm846dfbb2c27d1fe8f669b8f026/apprise - -# Note: You must have joined the channel to send a message to it! - -# Official API reference: https://developer.gitter.im/docs/user-resource - -import re -import requests -from json import loads -from json import dumps -from datetime import datetime - -from .NotifyBase import NotifyBase -from ..common import NotifyImageSize -from ..common import NotifyFormat -from ..common import NotifyType -from ..utils import parse_list -from ..utils import parse_bool -from ..utils import validate_regex -from ..AppriseLocale import gettext_lazy as _ - -# API Gitter URL -GITTER_API_URL = 'https://api.gitter.im/v1' - -# Used to break path apart into list of targets -TARGET_LIST_DELIM = re.compile(r'[ \t\r\n,\\/]+') - - -class NotifyGitter(NotifyBase): - """ - A wrapper for Gitter Notifications - """ - - # The default descriptive name associated with the Notification - service_name = 'Gitter' - - # The services URL - service_url = 'https://gitter.im/' - - # All notification requests are secure - secure_protocol = 'gitter' - - # A URL that takes you to the setup/help of the specific protocol - setup_url = 'https://github.com/caronc/apprise/wiki/Notify_gitter' - - # Allows the user to specify the NotifyImageSize object - image_size = NotifyImageSize.XY_32 - - # Gitter does not support a title - title_maxlen = 0 - - # Gitter is kind enough to return how many more requests we're allowed to - # continue to make within it's header response as: - # X-RateLimit-Reset: The epoc time (in seconds) we can expect our - # rate-limit to be reset. - # X-RateLimit-Remaining: an integer identifying how many requests we're - # still allow to make. - request_rate_per_sec = 0 - - # For Tracking Purposes - ratelimit_reset = datetime.utcnow() - - # Default to 1 - ratelimit_remaining = 1 - - # Default Notification Format - notify_format = NotifyFormat.MARKDOWN - - # Define object templates - templates = ( - '{schema}://{token}/{targets}/', - ) - - # Define our template tokens - template_tokens = dict(NotifyBase.template_tokens, **{ - 'token': { - 'name': _('Token'), - 'type': 'string', - 'private': True, - 'required': True, - 'regex': (r'^[a-z0-9]{40}$', 'i'), - }, - 'targets': { - 'name': _('Rooms'), - 'type': 'list:string', - }, - }) - - # Define our template arguments - template_args = dict(NotifyBase.template_args, **{ - 'image': { - 'name': _('Include Image'), - 'type': 'bool', - 'default': False, - 'map_to': 'include_image', - }, - 'to': { - 'alias_of': 'targets', - }, - }) - - def __init__(self, token, targets, include_image=False, **kwargs): - """ - Initialize Gitter Object - """ - super().__init__(**kwargs) - - # Secret Key (associated with project) - self.token = validate_regex( - token, *self.template_tokens['token']['regex']) - if not self.token: - msg = 'An invalid Gitter API Token ' \ - '({}) was specified.'.format(token) - self.logger.warning(msg) - raise TypeError(msg) - - # Parse our targets - self.targets = parse_list(targets) - if not self.targets: - msg = 'There are no valid Gitter targets to notify.' - self.logger.warning(msg) - raise TypeError(msg) - - # Used to track maping of rooms to their numeric id lookup for - # messaging - self._room_mapping = None - - # Track whether or not we want to send an image with our notification - # or not. - self.include_image = include_image - - return - - def send(self, body, title='', notify_type=NotifyType.INFO, **kwargs): - """ - Perform Gitter Notification - """ - - # error tracking (used for function return) - has_error = False - - # Set up our image for display if configured to do so - image_url = None if not self.include_image \ - else self.image_url(notify_type) - - if image_url: - body = '![alt]({})\n{}'.format(image_url, body) - - if self._room_mapping is None: - # Populate our room mapping - self._room_mapping = {} - postokay, response = self._fetch(url='rooms') - if not postokay: - return False - - # Response generally looks like this: - # [ - # { - # noindex: False, - # oneToOne: False, - # avatarUrl: 'https://path/to/avatar/url', - # url: '/apprise-notifications/community', - # public: True, - # tags: [], - # lurk: False, - # uri: 'apprise-notifications/community', - # lastAccessTime: '2019-03-25T00:12:28.144Z', - # topic: '', - # roomMember: True, - # groupId: '5c981cecd73408ce4fbbad2f', - # githubType: 'REPO_CHANNEL', - # unreadItems: 0, - # mentions: 0, - # security: 'PUBLIC', - # userCount: 1, - # id: '5c981cecd73408ce4fbbad31', - # name: 'apprise/community' - # } - # ] - for entry in response: - self._room_mapping[entry['name'].lower().split('/')[0]] = { - # The ID of the room - 'id': entry['id'], - - # A descriptive name (useful for logging) - 'uri': entry['uri'], - } - - # Create a copy of the targets list - targets = list(self.targets) - while len(targets): - target = targets.pop(0).lower() - - if target not in self._room_mapping: - self.logger.warning( - 'Failed to locate Gitter room {}'.format(target)) - - # Flag our error - has_error = True - continue - - # prepare our payload - payload = { - 'text': body, - } - - # Our Notification URL - notify_url = 'rooms/{}/chatMessages'.format( - self._room_mapping[target]['id']) - - # Perform our query - postokay, response = self._fetch( - notify_url, payload=dumps(payload), method='POST') - - if not postokay: - # Flag our error - has_error = True - - return not has_error - - def _fetch(self, url, payload=None, method='GET'): - """ - Wrapper to request object - - """ - - # Prepare our headers: - headers = { - 'User-Agent': self.app_id, - 'Accept': 'application/json', - 'Authorization': 'Bearer ' + self.token, - } - if payload: - # Only set our header payload if it's defined - headers['Content-Type'] = 'application/json' - - # Default content response object - content = {} - - # Update our URL - url = '{}/{}'.format(GITTER_API_URL, url) - - # Some Debug Logging - self.logger.debug('Gitter {} URL: {} (cert_verify={})'.format( - method, - url, self.verify_certificate)) - if payload: - self.logger.debug('Gitter Payload: {}' .format(payload)) - - # By default set wait to None - wait = None - - if self.ratelimit_remaining <= 0: - # Determine how long we should wait for or if we should wait at - # all. This isn't fool-proof because we can't be sure the client - # time (calling this script) is completely synced up with the - # Gitter server. One would hope we're on NTP and our clocks are - # the same allowing this to role smoothly: - - now = datetime.utcnow() - if now < self.ratelimit_reset: - # We need to throttle for the difference in seconds - # We add 0.5 seconds to the end just to allow a grace - # period. - wait = (self.ratelimit_reset - now).total_seconds() + 0.5 - - # Always call throttle before any remote server i/o is made - self.throttle(wait=wait) - - # fetch function - fn = requests.post if method == 'POST' else requests.get - try: - r = fn( - url, - data=payload, - headers=headers, - verify=self.verify_certificate, - timeout=self.request_timeout, - ) - - if r.status_code != requests.codes.ok: - # We had a problem - status_str = \ - NotifyGitter.http_response_code_lookup(r.status_code) - - self.logger.warning( - 'Failed to send Gitter {} to {}: ' - '{}error={}.'.format( - method, - url, - ', ' if status_str else '', - r.status_code)) - - self.logger.debug( - 'Response Details:\r\n{}'.format(r.content)) - - # Mark our failure - return (False, content) - - try: - content = loads(r.content) - - except (AttributeError, TypeError, ValueError): - # ValueError = r.content is Unparsable - # TypeError = r.content is None - # AttributeError = r is None - content = {} - - try: - self.ratelimit_remaining = \ - int(r.headers.get('X-RateLimit-Remaining')) - self.ratelimit_reset = datetime.utcfromtimestamp( - int(r.headers.get('X-RateLimit-Reset'))) - - except (TypeError, ValueError): - # This is returned if we could not retrieve this information - # gracefully accept this state and move on - pass - - except requests.RequestException as e: - self.logger.warning( - 'Exception received when sending Gitter {} to {}: '. - format(method, url)) - self.logger.debug('Socket Exception: %s' % str(e)) - - # Mark our failure - return (False, content) - - return (True, content) - - 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', - } - - # Extend our parameters - params.update(self.url_parameters(privacy=privacy, *args, **kwargs)) - - return '{schema}://{token}/{targets}/?{params}'.format( - schema=self.secure_protocol, - token=self.pprint(self.token, privacy, safe=''), - targets='/'.join( - [NotifyGitter.quote(x, safe='') for x in self.targets]), - params=NotifyGitter.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 - - results['token'] = NotifyGitter.unquote(results['host']) - - # Get our entries; split_path() looks after unquoting content for us - # by default - results['targets'] = NotifyGitter.split_path(results['fullpath']) - - # Support the 'to' variable so that we can support targets this way too - # The 'to' makes it easier to use yaml configuration - if 'to' in results['qsd'] and len(results['qsd']['to']): - results['targets'] += NotifyGitter.parse_list(results['qsd']['to']) - - # Include images with our message - results['include_image'] = \ - parse_bool(results['qsd'].get('image', False)) - - return results diff --git a/lib/apprise/plugins/NotifyGnome.py b/lib/apprise/plugins/NotifyGnome.py index dc23f736..26b616ee 100644 --- a/lib/apprise/plugins/NotifyGnome.py +++ b/lib/apprise/plugins/NotifyGnome.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -# BSD 3-Clause License +# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2023, Chris Caron @@ -14,10 +14,6 @@ # 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 diff --git a/lib/apprise/plugins/NotifyGoogleChat.py b/lib/apprise/plugins/NotifyGoogleChat.py index f65b6541..7119e742 100644 --- a/lib/apprise/plugins/NotifyGoogleChat.py +++ b/lib/apprise/plugins/NotifyGoogleChat.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -# BSD 3-Clause License +# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2023, Chris Caron @@ -14,10 +14,6 @@ # 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 diff --git a/lib/apprise/plugins/NotifyGotify.py b/lib/apprise/plugins/NotifyGotify.py index 37922568..e20aa03d 100644 --- a/lib/apprise/plugins/NotifyGotify.py +++ b/lib/apprise/plugins/NotifyGotify.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -# BSD 3-Clause License +# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2023, Chris Caron @@ -14,10 +14,6 @@ # 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 @@ -134,7 +130,6 @@ class NotifyGotify(NotifyBase): 'type': 'string', 'map_to': 'fullpath', 'default': '/', - 'required': True, }, 'port': { 'name': _('Port'), diff --git a/lib/apprise/plugins/NotifyGrowl.py b/lib/apprise/plugins/NotifyGrowl.py index 9240d62c..790945f0 100644 --- a/lib/apprise/plugins/NotifyGrowl.py +++ b/lib/apprise/plugins/NotifyGrowl.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -# BSD 3-Clause License +# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2023, Chris Caron @@ -14,10 +14,6 @@ # 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 diff --git a/lib/apprise/plugins/NotifyGuilded.py b/lib/apprise/plugins/NotifyGuilded.py index 8bb9aeea..066cddee 100644 --- a/lib/apprise/plugins/NotifyGuilded.py +++ b/lib/apprise/plugins/NotifyGuilded.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -# BSD 3-Clause License +# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2023, Chris Caron @@ -14,10 +14,6 @@ # 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 diff --git a/lib/apprise/plugins/NotifyHomeAssistant.py b/lib/apprise/plugins/NotifyHomeAssistant.py index a403356a..25d8f5fb 100644 --- a/lib/apprise/plugins/NotifyHomeAssistant.py +++ b/lib/apprise/plugins/NotifyHomeAssistant.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -# BSD 3-Clause License +# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2023, Chris Caron @@ -14,10 +14,6 @@ # 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 diff --git a/lib/apprise/plugins/NotifyIFTTT.py b/lib/apprise/plugins/NotifyIFTTT.py index 70d51aa6..2c386c6b 100644 --- a/lib/apprise/plugins/NotifyIFTTT.py +++ b/lib/apprise/plugins/NotifyIFTTT.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -# BSD 3-Clause License +# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2023, Chris Caron @@ -14,10 +14,6 @@ # 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 @@ -30,7 +26,6 @@ # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. -# # For this plugin to work, you need to add the Maker applet to your profile # Simply visit https://ifttt.com/search and search for 'Webhooks' # Or if you're signed in, click here: https://ifttt.com/maker_webhooks @@ -312,6 +307,12 @@ class NotifyIFTTT(NotifyBase): params=NotifyIFTTT.urlencode(params), ) + def __len__(self): + """ + Returns the number of targets associated with this notification + """ + return len(self.events) + @staticmethod def parse_url(url): """ diff --git a/lib/apprise/plugins/NotifyJSON.py b/lib/apprise/plugins/NotifyJSON.py index 509c7627..a8ab7adc 100644 --- a/lib/apprise/plugins/NotifyJSON.py +++ b/lib/apprise/plugins/NotifyJSON.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -# BSD 3-Clause License +# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2023, Chris Caron @@ -14,10 +14,6 @@ # 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 @@ -41,6 +37,17 @@ from ..common import NotifyType from ..AppriseLocale import gettext_lazy as _ +class JSONPayloadField: + """ + Identifies the fields available in the JSON Payload + """ + VERSION = 'version' + TITLE = 'title' + MESSAGE = 'message' + ATTACHMENTS = 'attachments' + MESSAGETYPE = 'type' + + # Defines the method to send the notification METHODS = ( 'POST', @@ -69,6 +76,9 @@ class NotifyJSON(NotifyBase): # A URL that takes you to the setup/help of the specific protocol setup_url = 'https://github.com/caronc/apprise/wiki/Notify_Custom_JSON' + # Support attachments + attachment_support = True + # Allows the user to specify the NotifyImageSize object image_size = NotifyImageSize.XY_128 @@ -76,6 +86,12 @@ class NotifyJSON(NotifyBase): # local anyway request_rate_per_sec = 0 + # Define the JSON version to place in all payloads + # Version: Major.Minor, Major is only updated if the entire schema is + # changed. If just adding new items (or removing old ones, only increment + # the Minor! + json_version = '1.0' + # Define object templates templates = ( '{schema}://{host}', @@ -246,7 +262,7 @@ class NotifyJSON(NotifyBase): # Track our potential attachments attachments = [] - if attach: + if attach and self.attachment_support: for attachment in attach: # Perform some simple error checking if not attachment: @@ -274,20 +290,30 @@ class NotifyJSON(NotifyBase): self.logger.debug('I/O Exception: %s' % str(e)) return False - # prepare JSON Object + # Prepare JSON Object payload = { - # Version: Major.Minor, Major is only updated if the entire - # schema is changed. If just adding new items (or removing - # old ones, only increment the Minor! - 'version': '1.0', - 'title': title, - 'message': body, - 'attachments': attachments, - 'type': notify_type, + JSONPayloadField.VERSION: self.json_version, + JSONPayloadField.TITLE: title, + JSONPayloadField.MESSAGE: body, + JSONPayloadField.ATTACHMENTS: attachments, + JSONPayloadField.MESSAGETYPE: notify_type, } - # Apply any/all payload over-rides defined - payload.update(self.payload_extras) + for key, value in self.payload_extras.items(): + + if key in payload: + if not value: + # Do not store element in payload response + del payload[key] + + else: + # Re-map + payload[value] = payload[key] + del payload[key] + + else: + # Append entry + payload[key] = value auth = None if self.user: diff --git a/lib/apprise/plugins/NotifyJoin.py b/lib/apprise/plugins/NotifyJoin.py index 91b2c86b..92af6c3f 100644 --- a/lib/apprise/plugins/NotifyJoin.py +++ b/lib/apprise/plugins/NotifyJoin.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -# BSD 3-Clause License +# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2023, Chris Caron @@ -14,10 +14,6 @@ # 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 @@ -174,7 +170,6 @@ class NotifyJoin(NotifyBase): 'targets': { 'name': _('Targets'), 'type': 'list:string', - 'required': True, }, }) @@ -373,6 +368,12 @@ class NotifyJoin(NotifyBase): for x in self.targets]), params=NotifyJoin.urlencode(params)) + def __len__(self): + """ + Returns the number of targets associated with this notification + """ + return len(self.targets) + @staticmethod def parse_url(url): """ diff --git a/lib/apprise/plugins/NotifyKavenegar.py b/lib/apprise/plugins/NotifyKavenegar.py index 84100b25..d1df47c9 100644 --- a/lib/apprise/plugins/NotifyKavenegar.py +++ b/lib/apprise/plugins/NotifyKavenegar.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -# BSD 3-Clause License +# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2023, Chris Caron @@ -14,10 +14,6 @@ # 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 @@ -324,6 +320,12 @@ class NotifyKavenegar(NotifyBase): [NotifyKavenegar.quote(x, safe='') for x in self.targets]), params=NotifyKavenegar.urlencode(params)) + def __len__(self): + """ + Returns the number of targets associated with this notification + """ + return len(self.targets) + @staticmethod def parse_url(url): """ diff --git a/lib/apprise/plugins/NotifyKumulos.py b/lib/apprise/plugins/NotifyKumulos.py index 27e0995c..6072340f 100644 --- a/lib/apprise/plugins/NotifyKumulos.py +++ b/lib/apprise/plugins/NotifyKumulos.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -# BSD 3-Clause License +# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2023, Chris Caron @@ -14,10 +14,6 @@ # 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 diff --git a/lib/apprise/plugins/NotifyLametric.py b/lib/apprise/plugins/NotifyLametric.py index 1b98b694..516ec27c 100644 --- a/lib/apprise/plugins/NotifyLametric.py +++ b/lib/apprise/plugins/NotifyLametric.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -# BSD 3-Clause License +# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2023, Chris Caron @@ -14,10 +14,6 @@ # 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 @@ -370,6 +366,7 @@ class NotifyLametric(NotifyBase): # Device Mode '{schema}://{apikey}@{host}', + '{schema}://{user}:{apikey}@{host}', '{schema}://{apikey}@{host}:{port}', '{schema}://{user}:{apikey}@{host}:{port}', ) @@ -404,7 +401,6 @@ class NotifyLametric(NotifyBase): 'host': { 'name': _('Hostname'), 'type': 'string', - 'required': True, }, 'port': { 'name': _('Port'), diff --git a/lib/apprise/plugins/NotifyLine.py b/lib/apprise/plugins/NotifyLine.py index 65cd0163..09d72fed 100644 --- a/lib/apprise/plugins/NotifyLine.py +++ b/lib/apprise/plugins/NotifyLine.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -# BSD 3-Clause License +# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2023, Chris Caron @@ -14,10 +14,6 @@ # 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 @@ -102,6 +98,7 @@ class NotifyLine(NotifyBase): 'targets': { 'name': _('Targets'), 'type': 'list:string', + 'required': True }, }) @@ -267,6 +264,12 @@ class NotifyLine(NotifyBase): params=NotifyLine.urlencode(params), ) + def __len__(self): + """ + Returns the number of targets associated with this notification + """ + return len(self.targets) + @staticmethod def parse_url(url): """ diff --git a/lib/apprise/plugins/NotifyMQTT.py b/lib/apprise/plugins/NotifyMQTT.py index 48094e5f..2372c8b4 100644 --- a/lib/apprise/plugins/NotifyMQTT.py +++ b/lib/apprise/plugins/NotifyMQTT.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -# BSD 3-Clause License +# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2023, Chris Caron @@ -14,10 +14,6 @@ # 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 @@ -427,6 +423,10 @@ class NotifyMQTT(NotifyBase): self.logger.debug('Socket Exception: %s' % str(e)) return False + if not has_error: + # Verbal notice + self.logger.info('Sent MQTT notification') + return not has_error def url(self, privacy=False, *args, **kwargs): @@ -476,6 +476,12 @@ class NotifyMQTT(NotifyBase): params=NotifyMQTT.urlencode(params), ) + def __len__(self): + """ + Returns the number of targets associated with this notification + """ + return len(self.topics) + @staticmethod def parse_url(url): """ diff --git a/lib/apprise/plugins/NotifyMSG91.py b/lib/apprise/plugins/NotifyMSG91.py index ec94ab9a..225a2d3d 100644 --- a/lib/apprise/plugins/NotifyMSG91.py +++ b/lib/apprise/plugins/NotifyMSG91.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -# BSD 3-Clause License +# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2023, Chris Caron @@ -14,10 +14,6 @@ # 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 @@ -35,50 +31,31 @@ # Get your (authkey) from the dashboard here: # - https://world.msg91.com/user/index.php#api # +# Note: You will need to define a template for this to work +# # Get details on the API used in this plugin here: -# - https://world.msg91.com/apidoc/textsms/send-sms.php - +# - https://docs.msg91.com/reference/send-sms +import re import requests - +from json import dumps from .NotifyBase import NotifyBase from ..common import NotifyType from ..utils import is_phone_no -from ..utils import parse_phone_no +from ..utils import parse_phone_no, parse_bool from ..utils import validate_regex from ..AppriseLocale import gettext_lazy as _ -class MSG91Route: +class MSG91PayloadField: """ - Transactional SMS Routes - route=1 for promotional, route=4 for transactional SMS. + Identifies the fields available in the JSON Payload """ - PROMOTIONAL = 1 - TRANSACTIONAL = 4 + BODY = 'body' + MESSAGETYPE = 'type' -# Used for verification -MSG91_ROUTES = ( - MSG91Route.PROMOTIONAL, - MSG91Route.TRANSACTIONAL, -) - - -class MSG91Country: - """ - Optional value that can be specified on the MSG91 api - """ - INTERNATIONAL = 0 - USA = 1 - INDIA = 91 - - -# Used for verification -MSG91_COUNTRIES = ( - MSG91Country.INTERNATIONAL, - MSG91Country.USA, - MSG91Country.INDIA, -) +# Add entries here that are reserved +RESERVED_KEYWORDS = ('mobiles', ) class NotifyMSG91(NotifyBase): @@ -99,7 +76,7 @@ class NotifyMSG91(NotifyBase): setup_url = 'https://github.com/caronc/apprise/wiki/Notify_msg91' # MSG91 uses the http protocol with JSON requests - notify_url = 'https://world.msg91.com/api/sendhttp.php' + notify_url = 'https://control.msg91.com/api/v5/flow/' # The maximum length of the body body_maxlen = 160 @@ -108,14 +85,24 @@ class NotifyMSG91(NotifyBase): # cause any title (if defined) to get placed into the message body. title_maxlen = 0 + # Our supported mappings and component keys + component_key_re = re.compile( + r'(?P((?P[a-z0-9_-])?|(?Pbody|type)))', re.IGNORECASE) + # Define object templates templates = ( - '{schema}://{authkey}/{targets}', - '{schema}://{sender}@{authkey}/{targets}', + '{schema}://{template}@{authkey}/{targets}', ) # Define our template tokens template_tokens = dict(NotifyBase.template_tokens, **{ + 'template': { + 'name': _('Template ID'), + 'type': 'string', + 'required': True, + 'private': True, + 'regex': (r'^[a-z0-9 _-]+$', 'i'), + }, 'authkey': { 'name': _('Authentication Key'), 'type': 'string', @@ -133,10 +120,7 @@ class NotifyMSG91(NotifyBase): 'targets': { 'name': _('Targets'), 'type': 'list:string', - }, - 'sender': { - 'name': _('Sender ID'), - 'type': 'string', + 'required': True, }, }) @@ -145,21 +129,23 @@ class NotifyMSG91(NotifyBase): 'to': { 'alias_of': 'targets', }, - 'route': { - 'name': _('Route'), - 'type': 'choice:int', - 'values': MSG91_ROUTES, - 'default': MSG91Route.TRANSACTIONAL, - }, - 'country': { - 'name': _('Country'), - 'type': 'choice:int', - 'values': MSG91_COUNTRIES, + 'short_url': { + 'name': _('Short URL'), + 'type': 'bool', + 'default': False, }, }) - def __init__(self, authkey, targets=None, sender=None, route=None, - country=None, **kwargs): + # Define any kwargs we're using + template_kwargs = { + 'template_mapping': { + 'name': _('Template Mapping'), + 'prefix': ':', + }, + } + + def __init__(self, template, authkey, targets=None, short_url=None, + template_mapping=None, **kwargs): """ Initialize MSG91 Object """ @@ -174,39 +160,20 @@ class NotifyMSG91(NotifyBase): self.logger.warning(msg) raise TypeError(msg) - if route is None: - self.route = self.template_args['route']['default'] + # Template ID + self.template = validate_regex( + template, *self.template_tokens['template']['regex']) + if not self.template: + msg = 'An invalid MSG91 Template ID ' \ + '({}) was specified.'.format(template) + self.logger.warning(msg) + raise TypeError(msg) + + if short_url is None: + self.short_url = self.template_args['short_url']['default'] else: - try: - self.route = int(route) - if self.route not in MSG91_ROUTES: - # Let outer except catch thi - raise ValueError() - - except (ValueError, TypeError): - msg = 'The MSG91 route specified ({}) is invalid.'\ - .format(route) - self.logger.warning(msg) - raise TypeError(msg) - - if country: - try: - self.country = int(country) - if self.country not in MSG91_COUNTRIES: - # Let outer except catch thi - raise ValueError() - - except (ValueError, TypeError): - msg = 'The MSG91 country specified ({}) is invalid.'\ - .format(country) - self.logger.warning(msg) - raise TypeError(msg) - else: - self.country = country - - # Store our sender - self.sender = sender + self.short_url = parse_bool(short_url) # Parse our targets self.targets = list() @@ -224,6 +191,11 @@ class NotifyMSG91(NotifyBase): # store valid phone number self.targets.append(result['full']) + self.template_mapping = {} + if template_mapping: + # Store our extra payload entries + self.template_mapping.update(template_mapping) + return def send(self, body, title='', notify_type=NotifyType.INFO, **kwargs): @@ -239,23 +211,55 @@ class NotifyMSG91(NotifyBase): # Prepare our headers headers = { 'User-Agent': self.app_id, - 'Content-Type': 'application/x-www-form-urlencoded', + 'Content-Type': 'application/json', + 'authkey': self.authkey, } + # Base + recipient_payload = { + 'mobiles': None, + # Keyword Tokens + MSG91PayloadField.BODY: body, + MSG91PayloadField.MESSAGETYPE: notify_type, + } + + # Prepare Recipient Payload Object + for key, value in self.template_mapping.items(): + + if key in RESERVED_KEYWORDS: + self.logger.warning( + 'Ignoring MSG91 custom payload entry %s', key) + continue + + if key in recipient_payload: + if not value: + # Do not store element in payload response + del recipient_payload[key] + + else: + # Re-map + recipient_payload[value] = recipient_payload[key] + del recipient_payload[key] + + else: + # Append entry + recipient_payload[key] = value + + # Prepare our recipients + recipients = [] + for target in self.targets: + recipient = recipient_payload.copy() + recipient['mobiles'] = target + recipients.append(recipient) + # Prepare our payload payload = { - 'sender': self.sender if self.sender else self.app_id, - 'authkey': self.authkey, - 'message': body, - 'response': 'json', + 'template_id': self.template, + 'short_url': 1 if self.short_url else 0, # target phone numbers are sent with a comma delimiter - 'mobiles': ','.join(self.targets), - 'route': str(self.route), + 'recipients': recipients, } - if self.country: - payload['country'] = str(self.country) - # Some Debug Logging self.logger.debug('MSG91 POST URL: {} (cert_verify={})'.format( self.notify_url, self.verify_certificate)) @@ -267,7 +271,7 @@ class NotifyMSG91(NotifyBase): try: r = requests.post( self.notify_url, - data=payload, + data=dumps(payload), headers=headers, verify=self.verify_certificate, timeout=self.request_timeout, @@ -313,22 +317,32 @@ class NotifyMSG91(NotifyBase): # Define any URL parameters params = { - 'route': str(self.route), + 'short_url': str(self.short_url), } # Extend our parameters params.update(self.url_parameters(privacy=privacy, *args, **kwargs)) - if self.country: - params['country'] = str(self.country) + # Payload body extras prefixed with a ':' sign + # Append our payload extras into our parameters + params.update( + {':{}'.format(k): v for k, v in self.template_mapping.items()}) - return '{schema}://{authkey}/{targets}/?{params}'.format( + return '{schema}://{template}@{authkey}/{targets}/?{params}'.format( schema=self.secure_protocol, + template=self.pprint(self.template, privacy, safe=''), authkey=self.pprint(self.authkey, privacy, safe=''), targets='/'.join( [NotifyMSG91.quote(x, safe='') for x in self.targets]), params=NotifyMSG91.urlencode(params)) + def __len__(self): + """ + Returns the number of targets associated with this notification + """ + targets = len(self.targets) + return targets if targets > 0 else 1 + @staticmethod def parse_url(url): """ @@ -349,11 +363,11 @@ class NotifyMSG91(NotifyBase): # The hostname is our authentication key results['authkey'] = NotifyMSG91.unquote(results['host']) - if 'route' in results['qsd'] and len(results['qsd']['route']): - results['route'] = results['qsd']['route'] + # The template id is kept in the user field + results['template'] = NotifyMSG91.unquote(results['user']) - if 'country' in results['qsd'] and len(results['qsd']['country']): - results['country'] = results['qsd']['country'] + if 'short_url' in results['qsd'] and len(results['qsd']['short_url']): + results['short_url'] = parse_bool(results['qsd']['short_url']) # Support the 'to' variable so that we can support targets this way too # The 'to' makes it easier to use yaml configuration @@ -361,4 +375,10 @@ class NotifyMSG91(NotifyBase): results['targets'] += \ NotifyMSG91.parse_phone_no(results['qsd']['to']) + # store any additional payload extra's defined + results['template_mapping'] = { + NotifyMSG91.unquote(x): NotifyMSG91.unquote(y) + for x, y in results['qsd:'].items() + } + return results diff --git a/lib/apprise/plugins/NotifyMSTeams.py b/lib/apprise/plugins/NotifyMSTeams.py index 19f9fe34..e82fdb8c 100644 --- a/lib/apprise/plugins/NotifyMSTeams.py +++ b/lib/apprise/plugins/NotifyMSTeams.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -# BSD 3-Clause License +# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2023, Chris Caron @@ -14,10 +14,6 @@ # 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 diff --git a/lib/apprise/plugins/NotifyMacOSX.py b/lib/apprise/plugins/NotifyMacOSX.py index 59c0620a..ae08da11 100644 --- a/lib/apprise/plugins/NotifyMacOSX.py +++ b/lib/apprise/plugins/NotifyMacOSX.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -# BSD 3-Clause License +# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2023, Chris Caron @@ -14,10 +14,6 @@ # 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 @@ -197,8 +193,7 @@ class NotifyMacOSX(NotifyBase): self.logger.debug('MacOSX CMD: {}'.format(' '.join(cmd))) # Send our notification - output = subprocess.Popen( - cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT) + output = subprocess.Popen(cmd) # Wait for process to complete output.wait() diff --git a/lib/apprise/plugins/NotifyMailgun.py b/lib/apprise/plugins/NotifyMailgun.py index f6017c82..5afebc52 100644 --- a/lib/apprise/plugins/NotifyMailgun.py +++ b/lib/apprise/plugins/NotifyMailgun.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -# BSD 3-Clause License +# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2023, Chris Caron @@ -14,10 +14,6 @@ # 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 @@ -121,6 +117,9 @@ class NotifyMailgun(NotifyBase): # A URL that takes you to the setup/help of the specific protocol setup_url = 'https://github.com/caronc/apprise/wiki/Notify_mailgun' + # Support attachments + attachment_support = True + # Default Notify Format notify_format = NotifyFormat.HTML @@ -152,8 +151,13 @@ class NotifyMailgun(NotifyBase): 'private': True, 'required': True, }, + 'target_email': { + 'name': _('Target Email'), + 'type': 'string', + 'map_to': 'targets', + }, 'targets': { - 'name': _('Target Emails'), + 'name': _('Targets'), 'type': 'list:string', }, }) @@ -366,7 +370,7 @@ class NotifyMailgun(NotifyBase): # Track our potential files files = {} - if attach: + if attach and self.attachment_support: for idx, attachment in enumerate(attach): # Perform some simple error checking if not attachment: @@ -627,6 +631,20 @@ class NotifyMailgun(NotifyBase): safe='') for e in self.targets]), params=NotifyMailgun.urlencode(params)) + def __len__(self): + """ + Returns the number of targets associated with this notification + """ + # + # Factor batch into calculation + # + batch_size = 1 if not self.batch else self.default_batch_size + targets = len(self.targets) + if batch_size > 1: + targets = int(targets / batch_size) + \ + (1 if targets % batch_size else 0) + return targets if targets > 0 else 1 + @staticmethod def parse_url(url): """ diff --git a/lib/apprise/plugins/NotifyMastodon.py b/lib/apprise/plugins/NotifyMastodon.py index cfd7ff48..90c39e14 100644 --- a/lib/apprise/plugins/NotifyMastodon.py +++ b/lib/apprise/plugins/NotifyMastodon.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -# BSD 3-Clause License +# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2023, Chris Caron @@ -14,10 +14,6 @@ # 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 @@ -35,6 +31,7 @@ import requests from copy import deepcopy from json import dumps, loads from datetime import datetime +from datetime import timezone from .NotifyBase import NotifyBase from ..URLBase import PrivacyMode @@ -110,6 +107,10 @@ class NotifyMastodon(NotifyBase): # A URL that takes you to the setup/help of the specific protocol setup_url = 'https://github.com/caronc/apprise/wiki/Notify_mastodon' + # Support attachments + attachment_support = True + + # Allows the user to specify the NotifyImageSize object # Allows the user to specify the NotifyImageSize object; this is supported # through the webhook image_size = NotifyImageSize.XY_128 @@ -150,7 +151,7 @@ class NotifyMastodon(NotifyBase): request_rate_per_sec = 0 # For Tracking Purposes - ratelimit_reset = datetime.utcnow() + ratelimit_reset = datetime.now(timezone.utc).replace(tzinfo=None) # Default to 1000; users can send up to 1000 DM's and 2400 toot a day # This value only get's adjusted if the server sets it that way @@ -378,6 +379,13 @@ class NotifyMastodon(NotifyBase): params=NotifyMastodon.urlencode(params), ) + def __len__(self): + """ + Returns the number of targets associated with this notification + """ + targets = len(self.targets) + return targets if targets > 0 else 1 + def send(self, body, title='', notify_type=NotifyType.INFO, attach=None, **kwargs): """ @@ -406,11 +414,10 @@ class NotifyMastodon(NotifyBase): else: targets.add(myself) - if attach: + if attach and self.attachment_support: # We need to upload our payload first so that we can source it # in remaining messages for attachment in attach: - # Perform some simple error checking if not attachment: # We could not access the attachment @@ -570,7 +577,7 @@ class NotifyMastodon(NotifyBase): _payload = deepcopy(payload) _payload['media_ids'] = media_ids - if no: + if no or not body: # strip text and replace it with the image representation _payload['status'] = \ '{:02d}/{:02d}'.format(no + 1, len(batches)) @@ -827,7 +834,7 @@ class NotifyMastodon(NotifyBase): # Mastodon server. One would hope we're on NTP and our clocks are # the same allowing this to role smoothly: - now = datetime.utcnow() + now = datetime.now(timezone.utc).replace(tzinfo=None) if now < self.ratelimit_reset: # We need to throttle for the difference in seconds # We add 0.5 seconds to the end just to allow a grace @@ -885,8 +892,9 @@ class NotifyMastodon(NotifyBase): # Capture rate limiting if possible self.ratelimit_remaining = \ int(r.headers.get('X-RateLimit-Remaining')) - self.ratelimit_reset = datetime.utcfromtimestamp( - int(r.headers.get('X-RateLimit-Limit'))) + self.ratelimit_reset = datetime.fromtimestamp( + int(r.headers.get('X-RateLimit-Limit')), timezone.utc + ).replace(tzinfo=None) except (TypeError, ValueError): # This is returned if we could not retrieve this information diff --git a/lib/apprise/plugins/NotifyMatrix.py b/lib/apprise/plugins/NotifyMatrix.py index ca9692aa..8f3e77ff 100644 --- a/lib/apprise/plugins/NotifyMatrix.py +++ b/lib/apprise/plugins/NotifyMatrix.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -# BSD 3-Clause License +# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2023, Chris Caron @@ -14,10 +14,6 @@ # 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 @@ -53,8 +49,11 @@ from ..utils import validate_regex from ..AppriseLocale import gettext_lazy as _ # Define default path -MATRIX_V2_API_PATH = '/_matrix/client/r0' MATRIX_V1_WEBHOOK_PATH = '/api/v1/matrix/hook' +MATRIX_V2_API_PATH = '/_matrix/client/r0' +MATRIX_V3_API_PATH = '/_matrix/client/v3' +MATRIX_V3_MEDIA_PATH = '/_matrix/media/v3' +MATRIX_V2_MEDIA_PATH = '/_matrix/media/r0' # Extend HTTP Error Messages MATRIX_HTTP_ERROR_MAP = { @@ -88,6 +87,21 @@ MATRIX_MESSAGE_TYPES = ( ) +class MatrixVersion: + # Version 2 + V2 = "2" + + # Version 3 + V3 = "3" + + +# webhook modes are placed into this list for validation purposes +MATRIX_VERSIONS = ( + MatrixVersion.V2, + MatrixVersion.V3, +) + + class MatrixWebhookMode: # Webhook Mode is disabled DISABLED = "off" @@ -128,6 +142,9 @@ class NotifyMatrix(NotifyBase): # The default secure protocol secure_protocol = 'matrixs' + # Support Attachments + attachment_support = True + # A URL that takes you to the setup/help of the specific protocol setup_url = 'https://github.com/caronc/apprise/wiki/Notify_matrix' @@ -147,6 +164,9 @@ class NotifyMatrix(NotifyBase): # Throttle a wee-bit to avoid thrashing request_rate_per_sec = 0.5 + # Our Matrix API Version + matrix_api_version = '3' + # How many retry attempts we'll make in the event the server asks us to # throttle back. default_retries = 2 @@ -175,7 +195,6 @@ class NotifyMatrix(NotifyBase): 'host': { 'name': _('Hostname'), 'type': 'string', - 'required': True, }, 'port': { 'name': _('Port'), @@ -194,6 +213,7 @@ class NotifyMatrix(NotifyBase): }, 'token': { 'name': _('Access Token'), + 'private': True, 'map_to': 'password', }, 'target_user': { @@ -234,6 +254,12 @@ class NotifyMatrix(NotifyBase): 'values': MATRIX_WEBHOOK_MODES, 'default': MatrixWebhookMode.DISABLED, }, + 'version': { + 'name': _('Matrix API Verion'), + 'type': 'choice:string', + 'values': MATRIX_VERSIONS, + 'default': MatrixVersion.V3, + }, 'msgtype': { 'name': _('Message Type'), 'type': 'choice:string', @@ -248,7 +274,7 @@ class NotifyMatrix(NotifyBase): }, }) - def __init__(self, targets=None, mode=None, msgtype=None, + def __init__(self, targets=None, mode=None, msgtype=None, version=None, include_image=False, **kwargs): """ Initialize Matrix Object @@ -282,6 +308,14 @@ class NotifyMatrix(NotifyBase): self.logger.warning(msg) raise TypeError(msg) + # Setup our version + self.version = self.template_args['version']['default'] \ + if not isinstance(version, str) else version + if self.version not in MATRIX_VERSIONS: + msg = 'The version specified ({}) is invalid.'.format(version) + self.logger.warning(msg) + raise TypeError(msg) + # Setup our message type self.msgtype = self.template_args['msgtype']['default'] \ if not isinstance(msgtype, str) else msgtype.lower() @@ -521,7 +555,8 @@ class NotifyMatrix(NotifyBase): return payload def _send_server_notification(self, body, title='', - notify_type=NotifyType.INFO, **kwargs): + notify_type=NotifyType.INFO, attach=None, + **kwargs): """ Perform Direct Matrix Server Notification (no webhook) """ @@ -548,6 +583,13 @@ class NotifyMatrix(NotifyBase): # Initiaize our error tracking has_error = False + attachments = None + if attach and self.attachment_support: + attachments = self._send_attachments(attach) + if attachments is False: + # take an early exit + return False + while len(rooms) > 0: # Get our room @@ -568,23 +610,47 @@ class NotifyMatrix(NotifyBase): image_url = None if not self.include_image else \ self.image_url(notify_type) - if image_url: - # Define our payload - image_payload = { - 'msgtype': 'm.image', - 'url': image_url, - 'body': '{}'.format(notify_type if not title else title), - } - # Build our path + # Build our path + if self.version == MatrixVersion.V3: + path = '/rooms/{}/send/m.room.message/0'.format( + NotifyMatrix.quote(room_id)) + + else: path = '/rooms/{}/send/m.room.message'.format( NotifyMatrix.quote(room_id)) - # Post our content - postokay, response = self._fetch(path, payload=image_payload) - if not postokay: - # Mark our failure - has_error = True - continue + if self.version == MatrixVersion.V2: + # + # Attachments don't work beyond V2 at this time + # + if image_url: + # Define our payload + image_payload = { + 'msgtype': 'm.image', + 'url': image_url, + 'body': '{}'.format( + notify_type if not title else title), + } + + # Post our content + postokay, response = self._fetch( + path, payload=image_payload) + if not postokay: + # Mark our failure + has_error = True + continue + + if attachments: + for attachment in attachments: + attachment['room_id'] = room_id + attachment['type'] = 'm.room.message' + + postokay, response = self._fetch( + path, payload=attachment) + if not postokay: + # Mark our failure + has_error = True + continue # Define our payload payload = { @@ -615,12 +681,10 @@ class NotifyMatrix(NotifyBase): ) }) - # Build our path - path = '/rooms/{}/send/m.room.message'.format( - NotifyMatrix.quote(room_id)) - # Post our content - postokay, response = self._fetch(path, payload=payload) + method = 'PUT' if self.version == MatrixVersion.V3 else 'POST' + postokay, response = self._fetch( + path, payload=payload, method=method) if not postokay: # Notify our user self.logger.warning( @@ -632,6 +696,62 @@ class NotifyMatrix(NotifyBase): return not has_error + def _send_attachments(self, attach): + """ + Posts all of the provided attachments + """ + + payloads = [] + if self.version != MatrixVersion.V2: + self.logger.warning( + 'Add ?v=2 to Apprise URL to support Attachments') + return next((False for a in attach if not a), []) + + for attachment in attach: + if not attachment: + # invalid attachment (bad file) + return False + + if not re.match(r'^image/', attachment.mimetype, re.I): + # unsuppored at this time + continue + + postokay, response = \ + self._fetch('/upload', attachment=attachment) + if not (postokay and isinstance(response, dict)): + # Failed to perform upload + return False + + # If we get here, we'll have a response that looks like: + # { + # "content_uri": "mxc://example.com/a-unique-key" + # } + + if self.version == MatrixVersion.V3: + # Prepare our payload + payloads.append({ + "body": attachment.name, + "info": { + "mimetype": attachment.mimetype, + "size": len(attachment), + }, + "msgtype": "m.image", + "url": response.get('content_uri'), + }) + + else: + # Prepare our payload + payloads.append({ + "info": { + "mimetype": attachment.mimetype, + }, + "msgtype": "m.image", + "body": "tta.webp", + "url": response.get('content_uri'), + }) + + return payloads + def _register(self): """ Register with the service if possible. @@ -695,12 +815,23 @@ class NotifyMatrix(NotifyBase): 'user/pass combo is missing.') return False - # Prepare our Registration Payload - payload = { - 'type': 'm.login.password', - 'user': self.user, - 'password': self.password, - } + # Prepare our Authentication Payload + if self.version == MatrixVersion.V3: + payload = { + 'type': 'm.login.password', + 'identifier': { + 'type': 'm.id.user', + 'user': self.user, + }, + 'password': self.password, + } + + else: + payload = { + 'type': 'm.login.password', + 'user': self.user, + 'password': self.password, + } # Build our URL postokay, response = self._fetch('/login', payload=payload) @@ -970,7 +1101,8 @@ class NotifyMatrix(NotifyBase): return None - def _fetch(self, path, payload=None, params=None, method='POST'): + def _fetch(self, path, payload=None, params=None, attachment=None, + method='POST'): """ Wrapper to request.post() to manage it's response better and make the send() function cleaner and easier to maintain. @@ -983,6 +1115,7 @@ class NotifyMatrix(NotifyBase): headers = { 'User-Agent': self.app_id, 'Content-Type': 'application/json', + 'Accept': 'application/json', } if self.access_token is not None: @@ -991,19 +1124,39 @@ class NotifyMatrix(NotifyBase): default_port = 443 if self.secure else 80 url = \ - '{schema}://{hostname}:{port}{matrix_api}{path}'.format( + '{schema}://{hostname}{port}'.format( schema='https' if self.secure else 'http', hostname=self.host, port='' if self.port is None - or self.port == default_port else self.port, - matrix_api=MATRIX_V2_API_PATH, - path=path) + or self.port == default_port else f':{self.port}') + + if path == '/upload': + if self.version == MatrixVersion.V3: + url += MATRIX_V3_MEDIA_PATH + path + + else: + url += MATRIX_V2_MEDIA_PATH + path + + params = {'filename': attachment.name} + with open(attachment.path, 'rb') as fp: + payload = fp.read() + + # Update our content type + headers['Content-Type'] = attachment.mimetype + + else: + if self.version == MatrixVersion.V3: + url += MATRIX_V3_API_PATH + path + + else: + url += MATRIX_V2_API_PATH + path # Our response object response = {} # fetch function - fn = requests.post if method == 'POST' else requests.get + fn = requests.post if method == 'POST' else ( + requests.put if method == 'PUT' else requests.get) # Define how many attempts we'll make if we get caught in a throttle # event @@ -1024,13 +1177,16 @@ class NotifyMatrix(NotifyBase): try: r = fn( url, - data=dumps(payload), + data=dumps(payload) if not attachment else payload, params=params, headers=headers, verify=self.verify_certificate, timeout=self.request_timeout, ) + self.logger.debug( + 'Matrix Response: code=%d, %s' % ( + r.status_code, str(r.content))) response = loads(r.content) if r.status_code == 429: @@ -1094,6 +1250,13 @@ class NotifyMatrix(NotifyBase): # Return; we're done return (False, response) + except (OSError, IOError) as e: + self.logger.warning( + 'An I/O error occurred while reading {}.'.format( + attachment.name if attachment else 'unknown file')) + self.logger.debug('I/O Exception: %s' % str(e)) + return (False, {}) + return (True, response) # If we get here, we ran out of retries @@ -1160,6 +1323,7 @@ class NotifyMatrix(NotifyBase): params = { 'image': 'yes' if self.include_image else 'no', 'mode': self.mode, + 'version': self.version, 'msgtype': self.msgtype, } @@ -1196,6 +1360,13 @@ class NotifyMatrix(NotifyBase): params=NotifyMatrix.urlencode(params), ) + def __len__(self): + """ + Returns the number of targets associated with this notification + """ + targets = len(self.rooms) + return targets if targets > 0 else 1 + @staticmethod def parse_url(url): """ @@ -1250,6 +1421,14 @@ class NotifyMatrix(NotifyBase): if 'token' in results['qsd'] and len(results['qsd']['token']): results['password'] = NotifyMatrix.unquote(results['qsd']['token']) + # Support the use of the version= or v= keyword + if 'version' in results['qsd'] and len(results['qsd']['version']): + results['version'] = \ + NotifyMatrix.unquote(results['qsd']['version']) + + elif 'v' in results['qsd'] and len(results['qsd']['v']): + results['version'] = NotifyMatrix.unquote(results['qsd']['v']) + return results @staticmethod @@ -1259,7 +1438,7 @@ class NotifyMatrix(NotifyBase): """ result = re.match( - r'^https?://webhooks\.t2bot\.io/api/v1/matrix/hook/' + r'^https?://webhooks\.t2bot\.io/api/v[0-9]+/matrix/hook/' r'(?P[A-Z0-9_-]+)/?' r'(?P\?.+)?$', url, re.I) diff --git a/lib/apprise/plugins/NotifyMatterMost.py b/lib/apprise/plugins/NotifyMatterMost.py index e62f653c..859fed31 100644 --- a/lib/apprise/plugins/NotifyMatterMost.py +++ b/lib/apprise/plugins/NotifyMatterMost.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -# BSD 3-Clause License +# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2023, Chris Caron @@ -14,10 +14,6 @@ # 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 @@ -91,11 +87,11 @@ class NotifyMattermost(NotifyBase): # Define object templates templates = ( '{schema}://{host}/{token}', - '{schema}://{host}/{token}:{port}', + '{schema}://{host}:{port}/{token}', + '{schema}://{host}/{fullpath}/{token}', + '{schema}://{host}:{port}/{fullpath}/{token}', '{schema}://{botname}@{host}/{token}', '{schema}://{botname}@{host}:{port}/{token}', - '{schema}://{host}/{fullpath}/{token}', - '{schema}://{host}/{fullpath}{token}:{port}', '{schema}://{botname}@{host}/{fullpath}/{token}', '{schema}://{botname}@{host}:{port}/{fullpath}/{token}', ) diff --git a/lib/apprise/plugins/NotifyMessageBird.py b/lib/apprise/plugins/NotifyMessageBird.py index 72c24b6a..4cb9d7b5 100644 --- a/lib/apprise/plugins/NotifyMessageBird.py +++ b/lib/apprise/plugins/NotifyMessageBird.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -# BSD 3-Clause License +# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2023, Chris Caron @@ -14,10 +14,6 @@ # 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 @@ -311,6 +307,13 @@ class NotifyMessageBird(NotifyBase): [NotifyMessageBird.quote(x, safe='') for x in self.targets]), params=NotifyMessageBird.urlencode(params)) + def __len__(self): + """ + Returns the number of targets associated with this notification + """ + targets = len(self.targets) + return targets if targets > 0 else 1 + @staticmethod def parse_url(url): """ diff --git a/lib/apprise/plugins/NotifyMisskey.py b/lib/apprise/plugins/NotifyMisskey.py index 54c4e628..57633a51 100644 --- a/lib/apprise/plugins/NotifyMisskey.py +++ b/lib/apprise/plugins/NotifyMisskey.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -# BSD 3-Clause License +# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2023, Chris Caron @@ -14,10 +14,6 @@ # 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 @@ -29,6 +25,7 @@ # 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. + # 1. visit https://misskey-hub.net/ and see what it's all about if you want. # Choose a service you want to create an account on from here: # https://misskey-hub.net/en/instances.html diff --git a/lib/apprise/plugins/NotifyNextcloud.py b/lib/apprise/plugins/NotifyNextcloud.py index 085d02d6..b1d623d0 100644 --- a/lib/apprise/plugins/NotifyNextcloud.py +++ b/lib/apprise/plugins/NotifyNextcloud.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -# BSD 3-Clause License +# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2023, Chris Caron @@ -14,10 +14,6 @@ # 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 @@ -67,6 +63,8 @@ class NotifyNextcloud(NotifyBase): # Define object templates templates = ( + '{schema}://{host}/{targets}', + '{schema}://{host}:{port}/{targets}', '{schema}://{user}:{password}@{host}/{targets}', '{schema}://{user}:{password}@{host}:{port}/{targets}', ) @@ -116,6 +114,10 @@ class NotifyNextcloud(NotifyBase): 'min': 1, 'default': 21, }, + 'url_prefix': { + 'name': _('URL Prefix'), + 'type': 'string', + }, 'to': { 'alias_of': 'targets', }, @@ -129,17 +131,15 @@ class NotifyNextcloud(NotifyBase): }, } - def __init__(self, targets=None, version=None, headers=None, **kwargs): + def __init__(self, targets=None, version=None, headers=None, + url_prefix=None, **kwargs): """ Initialize Nextcloud Object """ super().__init__(**kwargs) + # Store our targets self.targets = parse_list(targets) - if len(self.targets) == 0: - msg = 'At least one Nextcloud target user must be specified.' - self.logger.warning(msg) - raise TypeError(msg) self.version = self.template_args['version']['default'] if version is not None: @@ -155,6 +155,10 @@ class NotifyNextcloud(NotifyBase): self.logger.warning(msg) raise TypeError(msg) + # Support URL Prefix + self.url_prefix = '' if not url_prefix \ + else url_prefix.strip('/') + self.headers = {} if headers: # Store our extra headers @@ -167,6 +171,11 @@ class NotifyNextcloud(NotifyBase): Perform Nextcloud Notification """ + if len(self.targets) == 0: + # There were no services to notify + self.logger.warning('There were no Nextcloud targets to notify.') + return False + # Prepare our Header headers = { 'User-Agent': self.app_id, @@ -198,11 +207,11 @@ class NotifyNextcloud(NotifyBase): auth = (self.user, self.password) # Nextcloud URL based on version used - notify_url = '{schema}://{host}/ocs/v2.php/'\ + notify_url = '{schema}://{host}/{url_prefix}/ocs/v2.php/'\ 'apps/admin_notifications/' \ 'api/v1/notifications/{target}' \ if self.version < 21 else \ - '{schema}://{host}/ocs/v2.php/'\ + '{schema}://{host}/{url_prefix}/ocs/v2.php/'\ 'apps/notifications/'\ 'api/v2/admin_notifications/{target}' @@ -210,6 +219,7 @@ class NotifyNextcloud(NotifyBase): schema='https' if self.secure else 'http', host=self.host if not isinstance(self.port, int) else '{}:{}'.format(self.host, self.port), + url_prefix=self.url_prefix, target=target, ) @@ -279,6 +289,9 @@ class NotifyNextcloud(NotifyBase): # Set our version params['version'] = str(self.version) + if self.url_prefix: + params['url_prefix'] = self.url_prefix + # Extend our parameters params.update(self.url_parameters(privacy=privacy, *args, **kwargs)) @@ -312,6 +325,13 @@ class NotifyNextcloud(NotifyBase): params=NotifyNextcloud.urlencode(params), ) + def __len__(self): + """ + Returns the number of targets associated with this notification + """ + targets = len(self.targets) + return targets if targets else 1 + @staticmethod def parse_url(url): """ @@ -339,6 +359,12 @@ class NotifyNextcloud(NotifyBase): results['version'] = \ NotifyNextcloud.unquote(results['qsd']['version']) + # Support URL Prefixes + if 'url_prefix' in results['qsd'] \ + and len(results['qsd']['url_prefix']): + results['url_prefix'] = \ + NotifyNextcloud.unquote(results['qsd']['url_prefix']) + # 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'] = { diff --git a/lib/apprise/plugins/NotifyNextcloudTalk.py b/lib/apprise/plugins/NotifyNextcloudTalk.py index 18b191c2..4f6dc054 100644 --- a/lib/apprise/plugins/NotifyNextcloudTalk.py +++ b/lib/apprise/plugins/NotifyNextcloudTalk.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -# BSD 3-Clause License +# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2023, Chris Caron @@ -14,10 +14,6 @@ # 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 @@ -96,6 +92,11 @@ class NotifyNextcloudTalk(NotifyBase): 'private': True, 'required': True, }, + 'target_room_id': { + 'name': _('Room ID'), + 'type': 'string', + 'map_to': 'targets', + }, 'targets': { 'name': _('Targets'), 'type': 'list:string', @@ -103,6 +104,14 @@ class NotifyNextcloudTalk(NotifyBase): }, }) + # Define our template arguments + template_args = dict(NotifyBase.template_args, **{ + 'url_prefix': { + 'name': _('URL Prefix'), + 'type': 'string', + }, + }) + # Define any kwargs we're using template_kwargs = { 'headers': { @@ -111,7 +120,7 @@ class NotifyNextcloudTalk(NotifyBase): }, } - def __init__(self, targets=None, headers=None, **kwargs): + def __init__(self, targets=None, headers=None, url_prefix=None, **kwargs): """ Initialize Nextcloud Talk Object """ @@ -122,11 +131,12 @@ class NotifyNextcloudTalk(NotifyBase): self.logger.warning(msg) raise TypeError(msg) + # Store our targets self.targets = parse_list(targets) - if len(self.targets) == 0: - msg = 'At least one Nextcloud Talk Room ID must be specified.' - self.logger.warning(msg) - raise TypeError(msg) + + # Support URL Prefix + self.url_prefix = '' if not url_prefix \ + else url_prefix.strip('/') self.headers = {} if headers: @@ -140,6 +150,12 @@ class NotifyNextcloudTalk(NotifyBase): Perform Nextcloud Talk Notification """ + if len(self.targets) == 0: + # There were no services to notify + self.logger.warning( + 'There were no Nextcloud Talk targets to notify.') + return False + # Prepare our Header headers = { 'User-Agent': self.app_id, @@ -171,13 +187,14 @@ class NotifyNextcloudTalk(NotifyBase): } # Nextcloud Talk URL - notify_url = '{schema}://{host}'\ + notify_url = '{schema}://{host}/{url_prefix}'\ '/ocs/v2.php/apps/spreed/api/v1/chat/{target}' notify_url = notify_url.format( schema='https' if self.secure else 'http', host=self.host if not isinstance(self.port, int) else '{}:{}'.format(self.host, self.port), + url_prefix=self.url_prefix, target=target, ) @@ -200,7 +217,8 @@ class NotifyNextcloudTalk(NotifyBase): verify=self.verify_certificate, timeout=self.request_timeout, ) - if r.status_code != requests.codes.created: + if r.status_code not in ( + requests.codes.created, requests.codes.ok): # We had a problem status_str = \ NotifyNextcloudTalk.http_response_code_lookup( @@ -240,6 +258,14 @@ class NotifyNextcloudTalk(NotifyBase): Returns the URL built dynamically based on specified arguments. """ + # Our default set of parameters + params = self.url_parameters(privacy=privacy, *args, **kwargs) + + # Append our headers into our parameters + params.update({'+{}'.format(k): v for k, v in self.headers.items()}) + if self.url_prefix: + params['url_prefix'] = self.url_prefix + # Determine Authentication auth = '{user}:{password}@'.format( user=NotifyNextcloudTalk.quote(self.user, safe=''), @@ -249,7 +275,7 @@ class NotifyNextcloudTalk(NotifyBase): default_port = 443 if self.secure else 80 - return '{schema}://{auth}{hostname}{port}/{targets}' \ + return '{schema}://{auth}{hostname}{port}/{targets}?{params}' \ .format( schema=self.secure_protocol if self.secure else self.protocol, @@ -261,8 +287,16 @@ class NotifyNextcloudTalk(NotifyBase): else ':{}'.format(self.port), targets='/'.join([NotifyNextcloudTalk.quote(x) for x in self.targets]), + params=NotifyNextcloudTalk.urlencode(params), ) + def __len__(self): + """ + Returns the number of targets associated with this notification + """ + targets = len(self.targets) + return targets if targets else 1 + @staticmethod def parse_url(url): """ @@ -280,6 +314,12 @@ class NotifyNextcloudTalk(NotifyBase): results['targets'] = \ NotifyNextcloudTalk.split_path(results['fullpath']) + # Support URL Prefixes + if 'url_prefix' in results['qsd'] \ + and len(results['qsd']['url_prefix']): + results['url_prefix'] = \ + NotifyNextcloudTalk.unquote(results['qsd']['url_prefix']) + # 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'] = { diff --git a/lib/apprise/plugins/NotifyNotica.py b/lib/apprise/plugins/NotifyNotica.py index 90bf7ef1..f95baba3 100644 --- a/lib/apprise/plugins/NotifyNotica.py +++ b/lib/apprise/plugins/NotifyNotica.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -# BSD 3-Clause License +# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2023, Chris Caron @@ -14,10 +14,6 @@ # 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 @@ -112,12 +108,12 @@ class NotifyNotica(NotifyBase): '{schema}://{user}:{password}@{host}:{port}/{token}', # Self-hosted notica servers (with custom path) - '{schema}://{host}{path}{token}', - '{schema}://{host}:{port}{path}{token}', - '{schema}://{user}@{host}{path}{token}', - '{schema}://{user}@{host}:{port}{path}{token}', - '{schema}://{user}:{password}@{host}{path}{token}', - '{schema}://{user}:{password}@{host}:{port}{path}{token}', + '{schema}://{host}{path}/{token}', + '{schema}://{host}:{port}/{path}/{token}', + '{schema}://{user}@{host}/{path}/{token}', + '{schema}://{user}@{host}:{port}{path}/{token}', + '{schema}://{user}:{password}@{host}{path}/{token}', + '{schema}://{user}:{password}@{host}:{port}/{path}/{token}', ) # Define our template tokens diff --git a/lib/apprise/plugins/NotifyNotifiarr.py b/lib/apprise/plugins/NotifyNotifiarr.py new file mode 100644 index 00000000..748e3b7a --- /dev/null +++ b/lib/apprise/plugins/NotifyNotifiarr.py @@ -0,0 +1,472 @@ +# -*- coding: utf-8 -*- +# BSD 2-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. +# +# 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, + 'footer': self.app_desc, + }, + 'ids': { + 'channel': channel, + } + } + } + + if self.include_image and image_url: + payload['discord']['text']['icon'] = image_url + payload['discord']['images'] = { + 'thumbnail': 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/lib/apprise/plugins/NotifyNotifico.py b/lib/apprise/plugins/NotifyNotifico.py index 9b1661bf..8636e2e0 100644 --- a/lib/apprise/plugins/NotifyNotifico.py +++ b/lib/apprise/plugins/NotifyNotifico.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -# BSD 3-Clause License +# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2023, Chris Caron @@ -14,10 +14,6 @@ # 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 diff --git a/lib/apprise/plugins/NotifyNtfy.py b/lib/apprise/plugins/NotifyNtfy.py index 7efe3487..ceab5a2a 100644 --- a/lib/apprise/plugins/NotifyNtfy.py +++ b/lib/apprise/plugins/NotifyNtfy.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -# BSD 3-Clause License +# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2023, Chris Caron @@ -14,10 +14,6 @@ # 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 @@ -172,6 +168,9 @@ class NotifyNtfy(NotifyBase): # Default upstream/cloud host if none is defined cloud_notify_url = 'https://ntfy.sh' + # Support attachments + attachment_support = True + # Allows the user to specify the NotifyImageSize object image_size = NotifyImageSize.XY_256 @@ -405,14 +404,14 @@ class NotifyNtfy(NotifyBase): # Retrieve our topic topic = topics.pop() - if attach: + if attach and self.attachment_support: # We need to upload our payload first so that we can source it # in remaining messages for no, attachment in enumerate(attach): - # First message only includes the text - _body = body if not no else None - _title = title if not no else None + # First message only includes the text (if defined) + _body = body if not no and body else None + _title = title if not no and title else None # Perform some simple error checking if not attachment: @@ -453,10 +452,6 @@ class NotifyNtfy(NotifyBase): 'User-Agent': self.app_id, } - # Some default values for our request object to which we'll update - # depending on what our payload is - files = None - # See https://ntfy.sh/docs/publish/#publish-as-json data = {} @@ -494,11 +489,23 @@ class NotifyNtfy(NotifyBase): data['topic'] = topic virt_payload = data + if self.attach: + virt_payload['attach'] = self.attach + + if self.filename: + virt_payload['filename'] = self.filename + else: # Point our payload to our parameters virt_payload = params notify_url += '/{topic}'.format(topic=topic) + # Prepare our Header + virt_payload['filename'] = attach.name + + with open(attach.path, 'rb') as fp: + data = fp.read() + if image_url: headers['X-Icon'] = image_url @@ -523,18 +530,6 @@ class NotifyNtfy(NotifyBase): if self.__tags: headers['X-Tags'] = ",".join(self.__tags) - if isinstance(attach, AttachBase): - # Prepare our Header - params['filename'] = attach.name - - # prepare our files object - files = {'file': (attach.name, open(attach.path, 'rb'))} - - elif self.attach is not None: - data['attach'] = self.attach - if self.filename is not None: - data['filename'] = self.filename - self.logger.debug('ntfy POST URL: %s (cert_verify=%r)' % ( notify_url, self.verify_certificate, )) @@ -547,13 +542,15 @@ class NotifyNtfy(NotifyBase): # Default response type response = None + if not attach: + data = dumps(data) + try: r = requests.post( notify_url, params=params if params else None, - data=dumps(data) if data else None, + data=data, headers=headers, - files=files, auth=auth, verify=self.verify_certificate, timeout=self.request_timeout, @@ -608,7 +605,6 @@ class NotifyNtfy(NotifyBase): notify_url) + 'notification.' ) self.logger.debug('Socket Exception: %s' % str(e)) - return False, response except (OSError, IOError) as e: self.logger.warning( @@ -616,13 +612,8 @@ class NotifyNtfy(NotifyBase): attach.name if isinstance(attach, AttachBase) else virt_payload)) self.logger.debug('I/O Exception: %s' % str(e)) - return False, response - finally: - # Close our file (if it's open) stored in the second element - # of our files tuple (index 1) - if files: - files['file'][1].close() + return False, response def url(self, privacy=False, *args, **kwargs): """ @@ -698,6 +689,12 @@ class NotifyNtfy(NotifyBase): params=NotifyNtfy.urlencode(params) ) + def __len__(self): + """ + Returns the number of targets associated with this notification + """ + return len(self.topics) + @staticmethod def parse_url(url): """ diff --git a/lib/apprise/plugins/NotifyOffice365.py b/lib/apprise/plugins/NotifyOffice365.py index 0778dd85..f445bc49 100644 --- a/lib/apprise/plugins/NotifyOffice365.py +++ b/lib/apprise/plugins/NotifyOffice365.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -# BSD 3-Clause License +# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2023, Chris Caron @@ -14,10 +14,6 @@ # 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 @@ -148,8 +144,13 @@ class NotifyOffice365(NotifyBase): 'private': True, 'required': True, }, + 'target_email': { + 'name': _('Target Email'), + 'type': 'string', + 'map_to': 'targets', + }, 'targets': { - 'name': _('Target Emails'), + 'name': _('Targets'), 'type': 'list:string', }, }) @@ -596,6 +597,12 @@ class NotifyOffice365(NotifyBase): safe='') for e in self.targets]), params=NotifyOffice365.urlencode(params)) + def __len__(self): + """ + Returns the number of targets associated with this notification + """ + return len(self.targets) + @staticmethod def parse_url(url): """ diff --git a/lib/apprise/plugins/NotifyOneSignal.py b/lib/apprise/plugins/NotifyOneSignal.py index 70cf0a18..39dd7f20 100644 --- a/lib/apprise/plugins/NotifyOneSignal.py +++ b/lib/apprise/plugins/NotifyOneSignal.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -# BSD 3-Clause License +# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2023, Chris Caron @@ -14,10 +14,6 @@ # 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 @@ -51,7 +47,7 @@ from ..utils import is_email from ..AppriseLocale import gettext_lazy as _ -class OneSignalCategory(NotifyBase): +class OneSignalCategory: """ We define the different category types that we can notify via OneSignal """ @@ -92,7 +88,7 @@ class NotifyOneSignal(NotifyBase): image_size = NotifyImageSize.XY_72 # The maximum allowable batch sizes per message - maximum_batch_size = 2000 + default_batch_size = 2000 # Define object templates templates = ( @@ -121,7 +117,7 @@ class NotifyOneSignal(NotifyBase): 'private': True, 'required': True, }, - 'target_device': { + 'target_player': { 'name': _('Target Player ID'), 'type': 'string', 'map_to': 'targets', @@ -146,6 +142,7 @@ class NotifyOneSignal(NotifyBase): 'targets': { 'name': _('Targets'), 'type': 'list:string', + 'required': True, }, }) @@ -204,7 +201,7 @@ class NotifyOneSignal(NotifyBase): raise TypeError(msg) # Prepare Batch Mode Flag - self.batch_size = self.maximum_batch_size if batch else 1 + self.batch_size = self.default_batch_size if batch else 1 # Place a thumbnail image inline with the message body self.include_image = include_image @@ -432,6 +429,26 @@ class NotifyOneSignal(NotifyBase): params=NotifyOneSignal.urlencode(params), ) + def __len__(self): + """ + Returns the number of targets associated with this notification + """ + # + # Factor batch into calculation + # + if self.batch_size > 1: + # Batches can only be sent by group (you can't combine groups into + # a single batch) + total_targets = 0 + for k, m in self.targets.items(): + targets = len(m) + total_targets += int(targets / self.batch_size) + \ + (1 if targets % self.batch_size else 0) + return total_targets + + # Normal batch count; just count the targets + return sum([len(m) for _, m in self.targets.items()]) + @staticmethod def parse_url(url): """ diff --git a/lib/apprise/plugins/NotifyOpsgenie.py b/lib/apprise/plugins/NotifyOpsgenie.py index 858056b2..29cd0a20 100644 --- a/lib/apprise/plugins/NotifyOpsgenie.py +++ b/lib/apprise/plugins/NotifyOpsgenie.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -# BSD 3-Clause License +# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2023, Chris Caron @@ -14,10 +14,6 @@ # 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 @@ -172,7 +168,7 @@ class NotifyOpsgenie(NotifyBase): opsgenie_default_region = OpsgenieRegion.US # The maximum allowable targets within a notification - maximum_batch_size = 50 + default_batch_size = 50 # Define object templates templates = ( @@ -308,7 +304,7 @@ class NotifyOpsgenie(NotifyBase): self.details.update(details) # Prepare Batch Mode Flag - self.batch_size = self.maximum_batch_size if batch else 1 + self.batch_size = self.default_batch_size if batch else 1 # Assign our tags (if defined) self.__tags = parse_list(tags) @@ -536,6 +532,20 @@ class NotifyOpsgenie(NotifyBase): for x in self.targets]), params=NotifyOpsgenie.urlencode(params)) + def __len__(self): + """ + Returns the number of targets associated with this notification + """ + # + # Factor batch into calculation + # + targets = len(self.targets) + if self.batch_size > 1: + targets = int(targets / self.batch_size) + \ + (1 if targets % self.batch_size else 0) + + return targets if targets > 0 else 1 + @staticmethod def parse_url(url): """ diff --git a/lib/apprise/plugins/NotifyPagerDuty.py b/lib/apprise/plugins/NotifyPagerDuty.py index a2417275..1592f93c 100644 --- a/lib/apprise/plugins/NotifyPagerDuty.py +++ b/lib/apprise/plugins/NotifyPagerDuty.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -# BSD 3-Clause License +# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2023, Chris Caron @@ -14,10 +14,6 @@ # 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 @@ -142,7 +138,7 @@ class NotifyPagerDuty(NotifyBase): }, # Optional but triggers V2 API 'integrationkey': { - 'name': _('Routing Key'), + 'name': _('Integration Key'), 'type': 'string', 'private': True, 'required': True diff --git a/lib/apprise/plugins/NotifyPagerTree.py b/lib/apprise/plugins/NotifyPagerTree.py index 65a19f61..a1579c30 100644 --- a/lib/apprise/plugins/NotifyPagerTree.py +++ b/lib/apprise/plugins/NotifyPagerTree.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -# BSD 3-Clause License +# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2023, Chris Caron @@ -14,10 +14,6 @@ # 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 diff --git a/lib/apprise/plugins/NotifyParsePlatform.py b/lib/apprise/plugins/NotifyParsePlatform.py index 69efb61c..f3d7d635 100644 --- a/lib/apprise/plugins/NotifyParsePlatform.py +++ b/lib/apprise/plugins/NotifyParsePlatform.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -# BSD 3-Clause License +# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2023, Chris Caron @@ -14,10 +14,6 @@ # 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 @@ -30,8 +26,6 @@ # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. -# Official API reference: https://developer.gitter.im/docs/user-resource - import re import requests from json import dumps diff --git a/lib/apprise/plugins/NotifyPopcornNotify.py b/lib/apprise/plugins/NotifyPopcornNotify.py index 9ea0c77e..47a29614 100644 --- a/lib/apprise/plugins/NotifyPopcornNotify.py +++ b/lib/apprise/plugins/NotifyPopcornNotify.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -# BSD 3-Clause License +# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2023, Chris Caron @@ -14,10 +14,6 @@ # 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 @@ -93,6 +89,7 @@ class NotifyPopcornNotify(NotifyBase): 'targets': { 'name': _('Targets'), 'type': 'list:string', + 'required': True, } }) @@ -265,6 +262,21 @@ class NotifyPopcornNotify(NotifyBase): [NotifyPopcornNotify.quote(x, safe='') for x in self.targets]), params=NotifyPopcornNotify.urlencode(params)) + def __len__(self): + """ + Returns the number of targets associated with this notification + """ + # + # Factor batch into calculation + # + batch_size = 1 if not self.batch else self.default_batch_size + targets = len(self.targets) + if batch_size > 1: + targets = int(targets / batch_size) + \ + (1 if targets % batch_size else 0) + + return targets + @staticmethod def parse_url(url): """ diff --git a/lib/apprise/plugins/NotifyProwl.py b/lib/apprise/plugins/NotifyProwl.py index cebe0701..80f0aca3 100644 --- a/lib/apprise/plugins/NotifyProwl.py +++ b/lib/apprise/plugins/NotifyProwl.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -# BSD 3-Clause License +# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2023, Chris Caron @@ -14,10 +14,6 @@ # 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 diff --git a/lib/apprise/plugins/NotifyPushBullet.py b/lib/apprise/plugins/NotifyPushBullet.py index c37532d1..61e8db2d 100644 --- a/lib/apprise/plugins/NotifyPushBullet.py +++ b/lib/apprise/plugins/NotifyPushBullet.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -# BSD 3-Clause License +# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2023, Chris Caron @@ -14,10 +14,6 @@ # 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 @@ -75,6 +71,9 @@ class NotifyPushBullet(NotifyBase): # PushBullet uses the http protocol with JSON requests notify_url = 'https://api.pushbullet.com/v2/{}' + # Support attachments + attachment_support = True + # Define object templates templates = ( '{schema}://{accesstoken}', @@ -150,7 +149,7 @@ class NotifyPushBullet(NotifyBase): # Build a list of our attachments attachments = [] - if attach: + if attach and self.attachment_support: # We need to upload our payload first so that we can source it # in remaining messages for attachment in attach: @@ -261,14 +260,15 @@ class NotifyPushBullet(NotifyBase): "PushBullet recipient {} parsed as a device" .format(recipient)) - okay, response = self._send( - self.notify_url.format('pushes'), payload) - if not okay: - has_error = True - continue + if body: + okay, response = self._send( + self.notify_url.format('pushes'), payload) + if not okay: + has_error = True + continue - self.logger.info( - 'Sent PushBullet notification to "%s".' % (recipient)) + self.logger.info( + 'Sent PushBullet notification to "%s".' % (recipient)) for attach_payload in attachments: # Send our attachments to our same user (already prepared as @@ -406,6 +406,12 @@ class NotifyPushBullet(NotifyBase): targets=targets, params=NotifyPushBullet.urlencode(params)) + def __len__(self): + """ + Returns the number of targets associated with this notification + """ + return len(self.targets) + @staticmethod def parse_url(url): """ diff --git a/lib/apprise/plugins/NotifyPushDeer.py b/lib/apprise/plugins/NotifyPushDeer.py new file mode 100644 index 00000000..76805c34 --- /dev/null +++ b/lib/apprise/plugins/NotifyPushDeer.py @@ -0,0 +1,218 @@ +# -*- coding: utf-8 -*- +# BSD 2-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. +# +# 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 ..common import NotifyType +from .NotifyBase import NotifyBase +from ..utils import validate_regex +from ..AppriseLocale import gettext_lazy as _ + +# Syntax: +# schan://{key}/ + + +class NotifyPushDeer(NotifyBase): + """ + A wrapper for PushDeer Notifications + """ + + # The default descriptive name associated with the Notification + service_name = 'PushDeer' + + # The services URL + service_url = 'https://www.pushdeer.com/' + + # Insecure Protocol Access + protocol = 'pushdeer' + + # Secure Protocol + secure_protocol = 'pushdeers' + + # A URL that takes you to the setup/help of the specific protocol + setup_url = 'https://github.com/caronc/apprise/wiki/Notify_PushDeer' + + # Default hostname + default_hostname = 'api2.pushdeer.com' + + # PushDeer API + notify_url = '{schema}://{host}:{port}/message/push?pushkey={pushKey}' + + # Define object templates + templates = ( + '{schema}://{pushkey}', + '{schema}://{host}/{pushkey}', + '{schema}://{host}:{port}/{pushkey}', + ) + + # Define our template tokens + template_tokens = dict(NotifyBase.template_tokens, **{ + 'host': { + 'name': _('Hostname'), + 'type': 'string', + }, + 'port': { + 'name': _('Port'), + 'type': 'int', + 'min': 1, + 'max': 65535, + }, + 'pushkey': { + 'name': _('Pushkey'), + 'type': 'string', + 'private': True, + 'required': True, + 'regex': (r'^[a-z0-9]+$', 'i'), + }, + }) + + def __init__(self, pushkey, **kwargs): + """ + Initialize PushDeer Object + """ + super().__init__(**kwargs) + + # PushKey (associated with project) + self.push_key = validate_regex( + pushkey, *self.template_tokens['pushkey']['regex']) + if not self.push_key: + msg = 'An invalid PushDeer API Pushkey ' \ + '({}) was specified.'.format(pushkey) + self.logger.warning(msg) + raise TypeError(msg) + + def send(self, body, title='', notify_type=NotifyType.INFO, **kwargs): + """ + Perform PushDeer Notification + """ + + # Prepare our persistent_notification.create payload + payload = { + 'text': title if title else body, + 'type': 'text', + 'desp': body if title else '', + } + + # Set our schema + schema = 'https' if self.secure else 'http' + + # Set host + host = self.default_hostname + if self.host: + host = self.host + + # Set port + port = 443 if self.secure else 80 + if self.port: + port = self.port + + # Our Notification URL + notify_url = self.notify_url.format( + schema=schema, host=host, port=port, pushKey=self.push_key) + + # Some Debug Logging + self.logger.debug('PushDeer URL: {} (cert_verify={})'.format( + notify_url, self.verify_certificate)) + self.logger.debug('PushDeer Payload: {}'.format(payload)) + + # Always call throttle before any remote server i/o is made + self.throttle() + + try: + r = requests.post( + notify_url, + data=payload, + timeout=self.request_timeout, + ) + + if r.status_code != requests.codes.ok: + # We had a problem + status_str = \ + NotifyPushDeer.http_response_code_lookup( + r.status_code) + + self.logger.warning( + 'Failed to send PushDeer notification: ' + '{}{}error={}.'.format( + status_str, + ', ' if status_str else '', + r.status_code)) + + self.logger.debug( + 'Response Details:\r\n{}'.format(r.content)) + return False + + else: + self.logger.info('Sent PushDeer notification.') + + except requests.RequestException as e: + self.logger.warning( + 'A Connection error occured sending PushDeer ' + 'notification.' + ) + self.logger.debug('Socket Exception: %s' % str(e)) + return False + + return True + + def url(self, privacy=False): + """ + Returns the URL built dynamically based on specified arguments. + """ + + if self.host: + url = '{schema}://{host}{port}/{pushkey}' + else: + url = '{schema}://{pushkey}' + + return url.format( + schema=self.secure_protocol if self.secure else self.protocol, + host=self.host, + port='' if not self.port else ':{}'.format(self.port), + pushkey=self.pprint(self.push_key, privacy, safe='')) + + @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, verify_host=False) + if not results: + # We're done early as we couldn't parse the URL + return results + + fullpaths = NotifyPushDeer.split_path(results['fullpath']) + + if len(fullpaths) == 0: + results['pushkey'] = results['host'] + results['host'] = None + else: + results['pushkey'] = fullpaths.pop() + + return results diff --git a/lib/apprise/plugins/NotifyPushMe.py b/lib/apprise/plugins/NotifyPushMe.py new file mode 100644 index 00000000..8ef3c79c --- /dev/null +++ b/lib/apprise/plugins/NotifyPushMe.py @@ -0,0 +1,221 @@ +# -*- coding: utf-8 -*- +# BSD 2-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. +# +# 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 .NotifyBase import NotifyBase +from ..common import NotifyType +from ..common import NotifyFormat +from ..utils import validate_regex +from ..utils import parse_bool +from ..AppriseLocale import gettext_lazy as _ + + +class NotifyPushMe(NotifyBase): + """ + A wrapper for PushMe Notifications + """ + + # The default descriptive name associated with the Notification + service_name = 'PushMe' + + # The services URL + service_url = 'https://push.i-i.me/' + + # Insecure protocol (for those self hosted requests) + protocol = 'pushme' + + # A URL that takes you to the setup/help of the specific protocol + setup_url = 'https://github.com/caronc/apprise/wiki/Notify_pushme' + + # PushMe URL + notify_url = 'https://push.i-i.me/' + + # Define object templates + templates = ( + '{schema}://{token}', + ) + + # Define our template tokens + template_tokens = dict(NotifyBase.template_tokens, **{ + 'token': { + 'name': _('Token'), + 'type': 'string', + 'private': True, + 'required': True, + }, + }) + + # Define our template arguments + template_args = dict(NotifyBase.template_args, **{ + 'token': { + 'alias_of': 'token', + }, + 'push_key': { + 'alias_of': 'token', + }, + 'status': { + 'name': _('Show Status'), + 'type': 'bool', + 'default': True, + }, + }) + + def __init__(self, token, status=None, **kwargs): + """ + Initialize PushMe Object + """ + super().__init__(**kwargs) + + # Token (associated with project) + self.token = validate_regex(token) + if not self.token: + msg = 'An invalid PushMe Token ' \ + '({}) was specified.'.format(token) + self.logger.warning(msg) + raise TypeError(msg) + + # Set Status type + self.status = status + + return + + def send(self, body, title='', notify_type=NotifyType.INFO, **kwargs): + """ + Perform PushMe Notification + """ + + headers = { + 'User-Agent': self.app_id, + } + + # Prepare our payload + params = { + 'push_key': self.token, + 'title': title if not self.status + else '{} {}'.format(self.asset.ascii(notify_type), title), + 'content': body, + 'type': 'markdown' + if self.notify_format == NotifyFormat.MARKDOWN else 'text' + } + + self.logger.debug('PushMe POST URL: %s (cert_verify=%r)' % ( + self.notify_url, self.verify_certificate, + )) + self.logger.debug('PushMe Payload: %s' % str(params)) + + # Always call throttle before any remote server i/o is made + self.throttle() + + try: + r = requests.post( + self.notify_url, + params=params, + headers=headers, + verify=self.verify_certificate, + timeout=self.request_timeout, + ) + if r.status_code != requests.codes.ok: + # We had a problem + status_str = \ + NotifyPushMe.http_response_code_lookup(r.status_code) + + self.logger.warning( + 'Failed to send PushMe 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 PushMe notification.') + + except requests.RequestException as e: + self.logger.warning( + 'A Connection error occurred sending PushMe notification.', + ) + 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 = { + 'status': 'yes' if self.status else 'no', + } + + # Extend our parameters + params.update(self.url_parameters(privacy=privacy, *args, **kwargs)) + + # Official URLs are easy to assemble + return '{schema}://{token}/?{params}'.format( + schema=self.protocol, + token=self.pprint(self.token, privacy, safe=''), + params=NotifyPushMe.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 + + # Store our token using the host + results['token'] = NotifyPushMe.unquote(results['host']) + + # The 'token' makes it easier to use yaml configuration + if 'token' in results['qsd'] and len(results['qsd']['token']): + results['token'] = NotifyPushMe.unquote(results['qsd']['token']) + + elif 'push_key' in results['qsd'] and len(results['qsd']['push_key']): + # Support 'push_key' if specified + results['token'] = NotifyPushMe.unquote(results['qsd']['push_key']) + + # Get status switch + results['status'] = \ + parse_bool(results['qsd'].get('status', True)) + + return results diff --git a/lib/apprise/plugins/NotifyPushSafer.py b/lib/apprise/plugins/NotifyPushSafer.py index 48a0f3bb..9873bd8e 100644 --- a/lib/apprise/plugins/NotifyPushSafer.py +++ b/lib/apprise/plugins/NotifyPushSafer.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -# BSD 3-Clause License +# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2023, Chris Caron @@ -14,10 +14,6 @@ # 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 @@ -336,6 +332,9 @@ class NotifyPushSafer(NotifyBase): # The default secure protocol secure_protocol = 'psafers' + # Support attachments + attachment_support = True + # Number of requests to a allow per second request_rate_per_sec = 1.2 @@ -546,7 +545,7 @@ class NotifyPushSafer(NotifyBase): # Initialize our list of attachments attachments = [] - if attach: + if attach and self.attachment_support: # We need to upload our payload first so that we can source it # in remaining messages for attachment in attach: @@ -794,6 +793,12 @@ class NotifyPushSafer(NotifyBase): targets=targets, params=NotifyPushSafer.urlencode(params)) + def __len__(self): + """ + Returns the number of targets associated with this notification + """ + return len(self.targets) + @staticmethod def parse_url(url): """ diff --git a/lib/apprise/plugins/NotifyPushed.py b/lib/apprise/plugins/NotifyPushed.py index 822ea1ad..96e2e89d 100644 --- a/lib/apprise/plugins/NotifyPushed.py +++ b/lib/apprise/plugins/NotifyPushed.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -# BSD 3-Clause License +# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2023, Chris Caron @@ -14,10 +14,6 @@ # 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 @@ -329,6 +325,13 @@ class NotifyPushed(NotifyBase): )]), params=NotifyPushed.urlencode(params)) + def __len__(self): + """ + Returns the number of targets associated with this notification + """ + targets = len(self.channels) + len(self.users) + return targets if targets > 0 else 1 + @staticmethod def parse_url(url): """ diff --git a/lib/apprise/plugins/NotifyPushjet.py b/lib/apprise/plugins/NotifyPushjet.py index c6e36a39..50ee16e4 100644 --- a/lib/apprise/plugins/NotifyPushjet.py +++ b/lib/apprise/plugins/NotifyPushjet.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -# BSD 3-Clause License +# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2023, Chris Caron @@ -14,10 +14,6 @@ # 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 diff --git a/lib/apprise/plugins/NotifyPushover.py b/lib/apprise/plugins/NotifyPushover.py index 1e943db1..4a76e7d5 100644 --- a/lib/apprise/plugins/NotifyPushover.py +++ b/lib/apprise/plugins/NotifyPushover.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -# BSD 3-Clause License +# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2023, Chris Caron @@ -14,10 +14,6 @@ # 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 @@ -32,6 +28,7 @@ import re import requests +from itertools import chain from .NotifyBase import NotifyBase from ..common import NotifyType @@ -46,7 +43,7 @@ from ..attachment.AttachBase import AttachBase PUSHOVER_SEND_TO_ALL = 'ALL_DEVICES' # Used to detect a Device -VALIDATE_DEVICE = re.compile(r'^[a-z0-9_]{1,25}$', re.I) +VALIDATE_DEVICE = re.compile(r'^\s*(?P[a-z0-9_-]{1,25})\s*$', re.I) # Priorities @@ -164,6 +161,9 @@ class NotifyPushover(NotifyBase): # Pushover uses the http protocol with JSON requests notify_url = 'https://api.pushover.net/1/messages.json' + # Support attachments + attachment_support = True + # The maximum allowable characters allowed in the body per message body_maxlen = 1024 @@ -201,7 +201,7 @@ class NotifyPushover(NotifyBase): 'target_device': { 'name': _('Target Device'), 'type': 'string', - 'regex': (r'^[a-z0-9_]{1,25}$', 'i'), + 'regex': (r'^[a-z0-9_-]{1,25}$', 'i'), 'map_to': 'targets', }, 'targets': { @@ -276,10 +276,30 @@ class NotifyPushover(NotifyBase): self.logger.warning(msg) raise TypeError(msg) - self.targets = parse_list(targets) - if len(self.targets) == 0: + # Track our valid devices + targets = parse_list(targets) + + # Track any invalid entries + self.invalid_targets = list() + + if len(targets) == 0: self.targets = (PUSHOVER_SEND_TO_ALL, ) + else: + self.targets = [] + for target in targets: + result = VALIDATE_DEVICE.match(target) + if result: + # Store device information + self.targets.append(result.group('device')) + continue + + self.logger.warning( + 'Dropped invalid Pushover device ' + '({}) specified.'.format(target), + ) + self.invalid_targets.append(target) + # Setup supplemental url self.supplemental_url = supplemental_url self.supplemental_url_title = supplemental_url_title @@ -288,9 +308,8 @@ class NotifyPushover(NotifyBase): self.sound = NotifyPushover.default_pushover_sound \ if not isinstance(sound, str) else sound.lower() if self.sound and self.sound not in PUSHOVER_SOUNDS: - msg = 'The sound specified ({}) is invalid.'.format(sound) - self.logger.warning(msg) - raise TypeError(msg) + msg = 'Using custom sound specified ({}). '.format(sound) + self.logger.debug(msg) # The Priority of the message self.priority = int( @@ -338,77 +357,67 @@ class NotifyPushover(NotifyBase): Perform Pushover Notification """ - # error tracking (used for function return) - has_error = False + if not self.targets: + # There were no services to notify + self.logger.warning( + 'There were no Pushover targets to notify.') + return False - # Create a copy of the devices list - devices = list(self.targets) - while len(devices): - device = devices.pop(0) + # prepare JSON Object + payload = { + 'token': self.token, + 'user': self.user_key, + 'priority': str(self.priority), + 'title': title if title else self.app_desc, + 'message': body, + 'device': ','.join(self.targets), + 'sound': self.sound, + } - if VALIDATE_DEVICE.match(device) is None: - self.logger.warning( - 'The device specified (%s) is invalid.' % device, - ) + if self.supplemental_url: + payload['url'] = self.supplemental_url - # Mark our failure - has_error = True - continue + if self.supplemental_url_title: + payload['url_title'] = self.supplemental_url_title - # prepare JSON Object - payload = { - 'token': self.token, - 'user': self.user_key, - 'priority': str(self.priority), - 'title': title if title else self.app_desc, - 'message': body, - 'device': device, - 'sound': self.sound, - } + if self.notify_format == NotifyFormat.HTML: + # https://pushover.net/api#html + payload['html'] = 1 - if self.supplemental_url: - payload['url'] = self.supplemental_url - if self.supplemental_url_title: - payload['url_title'] = self.supplemental_url_title + elif self.notify_format == NotifyFormat.MARKDOWN: + payload['message'] = convert_between( + NotifyFormat.MARKDOWN, NotifyFormat.HTML, body) + payload['html'] = 1 - if self.notify_format == NotifyFormat.HTML: - # https://pushover.net/api#html - payload['html'] = 1 - elif self.notify_format == NotifyFormat.MARKDOWN: - payload['message'] = convert_between( - NotifyFormat.MARKDOWN, NotifyFormat.HTML, body) - payload['html'] = 1 + if self.priority == PushoverPriority.EMERGENCY: + payload.update({'retry': self.retry, 'expire': self.expire}) - if self.priority == PushoverPriority.EMERGENCY: - payload.update({'retry': self.retry, 'expire': self.expire}) - - if attach: - # Create a copy of our payload - _payload = payload.copy() - - # Send with attachments - for attachment in attach: - # Simple send - if not self._send(_payload, attachment): - # Mark our failure - has_error = True - # clean exit from our attachment loop - break + if attach and self.attachment_support: + # Create a copy of our payload + _payload = payload.copy() + # Send with attachments + for no, attachment in enumerate(attach): + if no or not body: # To handle multiple attachments, clean up our message - _payload['title'] = '...' _payload['message'] = attachment.name - # No need to alarm for each consecutive attachment uploaded - # afterwards - _payload['sound'] = PushoverSound.NONE - else: - # Simple send - if not self._send(payload): + if not self._send(_payload, attachment): # Mark our failure - has_error = True + return False - return not has_error + # Clear our title if previously set + _payload['title'] = '' + + # No need to alarm for each consecutive attachment uploaded + # afterwards + _payload['sound'] = PushoverSound.NONE + + else: + # Simple send + return self._send(payload) + + return True def _send(self, payload, attach=None): """ @@ -562,8 +571,9 @@ class NotifyPushover(NotifyBase): params.update(self.url_parameters(privacy=privacy, *args, **kwargs)) # Escape our devices - devices = '/'.join([NotifyPushover.quote(x, safe='') - for x in self.targets]) + devices = '/'.join( + [NotifyPushover.quote(x, safe='') + for x in chain(self.targets, self.invalid_targets)]) if devices == PUSHOVER_SEND_TO_ALL: # keyword is reserved for internal usage only; it's safe to remove diff --git a/lib/apprise/plugins/NotifyPushy.py b/lib/apprise/plugins/NotifyPushy.py new file mode 100644 index 00000000..2a8a456b --- /dev/null +++ b/lib/apprise/plugins/NotifyPushy.py @@ -0,0 +1,384 @@ +# -*- coding: utf-8 -*- +# BSD 2-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. +# +# 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. + +# API reference: https://pushy.me/docs/api/send-notifications +import re +import requests +from itertools import chain + +from json import dumps, loads +from .NotifyBase import NotifyBase +from ..common import NotifyType +from ..utils import parse_list +from ..utils import validate_regex +from ..AppriseLocale import gettext_lazy as _ + +# Used to detect a Device and Topic +VALIDATE_DEVICE = re.compile(r'^@(?P[a-z0-9]+)$', re.I) +VALIDATE_TOPIC = re.compile(r'^[#]?(?P[a-z0-9]+)$', re.I) + +# Extend HTTP Error Messages +PUSHY_HTTP_ERROR_MAP = { + 401: 'Unauthorized - Invalid Token.', +} + + +class NotifyPushy(NotifyBase): + """ + A wrapper for Pushy Notifications + """ + + # The default descriptive name associated with the Notification + service_name = 'Pushy' + + # The services URL + service_url = 'https://pushy.me/' + + # All Pushy requests are secure + secure_protocol = 'pushy' + + # A URL that takes you to the setup/help of the specific protocol + setup_url = 'https://github.com/caronc/apprise/wiki/Notify_pushy' + + # Pushy uses the http protocol with JSON requests + notify_url = 'https://api.pushy.me/push?api_key={apikey}' + + # The maximum allowable characters allowed in the body per message + body_maxlen = 4096 + + # Define object templates + templates = ( + '{schema}://{apikey}/{targets}', + ) + + # Define our template tokens + template_tokens = dict(NotifyBase.template_tokens, **{ + 'apikey': { + 'name': _('Secret API Key'), + 'type': 'string', + 'private': True, + 'required': True, + }, + 'target_device': { + 'name': _('Target Device'), + 'type': 'string', + 'prefix': '@', + 'map_to': 'targets', + }, + 'target_topic': { + 'name': _('Target Topic'), + 'type': 'string', + 'prefix': '#', + 'map_to': 'targets', + }, + 'targets': { + 'name': _('Targets'), + 'type': 'list:string', + 'required': True, + }, + }) + + # Define our template arguments + template_args = dict(NotifyBase.template_args, **{ + 'sound': { + # Specify something like ping.aiff + 'name': _('Sound'), + 'type': 'string', + }, + 'badge': { + 'name': _('Badge'), + 'type': 'int', + 'min': 0, + }, + 'to': { + 'alias_of': 'targets', + }, + 'key': { + 'alias_of': 'apikey', + }, + }) + + def __init__(self, apikey, targets=None, sound=None, badge=None, **kwargs): + """ + Initialize Pushy Object + """ + super().__init__(**kwargs) + + # Access Token (associated with project) + self.apikey = validate_regex(apikey) + if not self.apikey: + msg = 'An invalid Pushy Secret API Key ' \ + '({}) was specified.'.format(apikey) + self.logger.warning(msg) + raise TypeError(msg) + + # Get our targets + self.devices = [] + self.topics = [] + + for target in parse_list(targets): + result = VALIDATE_TOPIC.match(target) + if result: + self.topics.append(result.group('topic')) + continue + + result = VALIDATE_DEVICE.match(target) + if result: + self.devices.append(result.group('device')) + continue + + self.logger.warning( + 'Dropped invalid topic/device ' + '({}) specified.'.format(target), + ) + + # Setup our sound + self.sound = sound + + # Badge + try: + # Acquire our badge count if we can: + # - We accept both the integer form as well as a string + # representation + self.badge = int(badge) + if self.badge < 0: + raise ValueError() + + except TypeError: + # NoneType means use Default; this is an okay exception + self.badge = None + + except ValueError: + self.badge = None + self.logger.warning( + 'The specified Pushy badge ({}) is not valid ', badge) + + return + + def send(self, body, title='', notify_type=NotifyType.INFO, **kwargs): + """ + Perform Pushy Notification + """ + + if len(self.topics) + len(self.devices) == 0: + # There were no services to notify + self.logger.warning('There were no Pushy targets to notify.') + return False + + # error tracking (used for function return) + has_error = False + + # Default Header + headers = { + 'User-Agent': self.app_id, + 'Content-Type': 'application/json', + 'Accepts': 'application/json', + } + + # Our URL + notify_url = self.notify_url.format(apikey=self.apikey) + + # Default content response object + content = {} + + # Create a copy of targets (topics and devices) + targets = list(self.topics) + list(self.devices) + while len(targets): + target = targets.pop(0) + + # prepare JSON Object + payload = { + # Mandatory fields + 'to': target, + "data": { + "message": body, + }, + "notification": { + 'body': body, + } + } + + # Optional payload items + if title: + payload['notification']['title'] = title + + if self.sound: + payload['notification']['sound'] = self.sound + + if self.badge is not None: + payload['notification']['badge'] = self.badge + + self.logger.debug('Pushy POST URL: %s (cert_verify=%r)' % ( + notify_url, self.verify_certificate, + )) + self.logger.debug('Pushy 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, + ) + + # Sample response + # See: https://pushy.me/docs/api/send-notifications + # { + # "success": true, + # "id": "5ea9b214b47cad768a35f13a", + # "info": { + # "devices": 1 + # "failed": ['abc'] + # } + # } + try: + content = loads(r.content) + + except (AttributeError, TypeError, ValueError): + # ValueError = r.content is Unparsable + # TypeError = r.content is None + # AttributeError = r is None + content = { + "success": False, + "id": '', + "info": {}, + } + + if r.status_code != requests.codes.ok \ + or not content.get('success'): + + # We had a problem + status_str = \ + NotifyPushy.http_response_code_lookup( + r.status_code, PUSHY_HTTP_ERROR_MAP) + + self.logger.warning( + 'Failed to send Pushy notification to {}: ' + '{}{}error={}.'.format( + target, + status_str, + ', ' if status_str else '', + r.status_code)) + + self.logger.debug( + 'Response Details:\r\n{}'.format(r.content)) + + has_error = True + continue + + else: + self.logger.info( + 'Sent Pushy notification to %s.' % target) + + except requests.RequestException as e: + self.logger.warning( + 'A Connection error occurred sending Pushy:%s ' + 'notification', target) + self.logger.debug('Socket Exception: %s' % str(e)) + + has_error = True + continue + + return not has_error + + def url(self, privacy=False, *args, **kwargs): + """ + Returns the URL built dynamically based on specified arguments. + """ + + # Define any URL parameters + params = {} + if self.sound: + params['sound'] = self.sound + + if self.badge is not None: + params['badge'] = str(self.badge) + + # 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( + [NotifyPushy.quote(x, safe='@#') for x in chain( + # Topics are prefixed with a pound/hashtag symbol + ['#{}'.format(x) for x in self.topics], + # Devices + ['@{}'.format(x) for x in self.devices], + )]), + params=NotifyPushy.urlencode(params)) + + def __len__(self): + """ + Returns the number of targets associated with this notification + """ + return len(self.topics) + len(self.devices) + + @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 + + # Token + results['apikey'] = NotifyPushy.unquote(results['host']) + + # Retrieve all of our targets + results['targets'] = NotifyPushy.split_path(results['fullpath']) + + # Get the sound + if 'sound' in results['qsd'] and len(results['qsd']['sound']): + results['sound'] = \ + NotifyPushy.unquote(results['qsd']['sound']) + + # Badge + if 'badge' in results['qsd'] and results['qsd']['badge']: + results['badge'] = NotifyPushy.unquote( + results['qsd']['badge'].strip()) + + # Support key variable to store Secret API Key + if 'key' in results['qsd'] and len(results['qsd']['key']): + results['apikey'] = results['qsd']['key'] + + # The 'to' makes it easier to use yaml configuration + if 'to' in results['qsd'] and len(results['qsd']['to']): + results['targets'] += \ + NotifyPushy.parse_list(results['qsd']['to']) + + return results diff --git a/lib/apprise/plugins/NotifyRSyslog.py b/lib/apprise/plugins/NotifyRSyslog.py new file mode 100644 index 00000000..473e4c5c --- /dev/null +++ b/lib/apprise/plugins/NotifyRSyslog.py @@ -0,0 +1,376 @@ +# -*- coding: utf-8 -*- +# BSD 2-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. +# +# 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 os +import socket + +from .NotifyBase import NotifyBase +from ..common import NotifyType +from ..utils import parse_bool +from ..AppriseLocale import gettext_lazy as _ + + +class syslog: + """ + Extrapoloated information from the syslog library so that this plugin + would not be dependent on it. + """ + # Notification Categories + LOG_KERN = 0 + LOG_USER = 8 + LOG_MAIL = 16 + LOG_DAEMON = 24 + LOG_AUTH = 32 + LOG_SYSLOG = 40 + LOG_LPR = 48 + LOG_NEWS = 56 + LOG_UUCP = 64 + LOG_CRON = 72 + LOG_LOCAL0 = 128 + LOG_LOCAL1 = 136 + LOG_LOCAL2 = 144 + LOG_LOCAL3 = 152 + LOG_LOCAL4 = 160 + LOG_LOCAL5 = 168 + LOG_LOCAL6 = 176 + LOG_LOCAL7 = 184 + + # Notification Types + LOG_INFO = 6 + LOG_NOTICE = 5 + LOG_WARNING = 4 + LOG_CRIT = 2 + + +class SyslogFacility: + """ + All of the supported facilities + """ + KERN = 'kern' + USER = 'user' + MAIL = 'mail' + DAEMON = 'daemon' + AUTH = 'auth' + SYSLOG = 'syslog' + LPR = 'lpr' + NEWS = 'news' + UUCP = 'uucp' + CRON = 'cron' + LOCAL0 = 'local0' + LOCAL1 = 'local1' + LOCAL2 = 'local2' + LOCAL3 = 'local3' + LOCAL4 = 'local4' + LOCAL5 = 'local5' + LOCAL6 = 'local6' + LOCAL7 = 'local7' + + +SYSLOG_FACILITY_MAP = { + SyslogFacility.KERN: syslog.LOG_KERN, + SyslogFacility.USER: syslog.LOG_USER, + SyslogFacility.MAIL: syslog.LOG_MAIL, + SyslogFacility.DAEMON: syslog.LOG_DAEMON, + SyslogFacility.AUTH: syslog.LOG_AUTH, + SyslogFacility.SYSLOG: syslog.LOG_SYSLOG, + SyslogFacility.LPR: syslog.LOG_LPR, + SyslogFacility.NEWS: syslog.LOG_NEWS, + SyslogFacility.UUCP: syslog.LOG_UUCP, + SyslogFacility.CRON: syslog.LOG_CRON, + SyslogFacility.LOCAL0: syslog.LOG_LOCAL0, + SyslogFacility.LOCAL1: syslog.LOG_LOCAL1, + SyslogFacility.LOCAL2: syslog.LOG_LOCAL2, + SyslogFacility.LOCAL3: syslog.LOG_LOCAL3, + SyslogFacility.LOCAL4: syslog.LOG_LOCAL4, + SyslogFacility.LOCAL5: syslog.LOG_LOCAL5, + SyslogFacility.LOCAL6: syslog.LOG_LOCAL6, + SyslogFacility.LOCAL7: syslog.LOG_LOCAL7, +} + +SYSLOG_FACILITY_RMAP = { + syslog.LOG_KERN: SyslogFacility.KERN, + syslog.LOG_USER: SyslogFacility.USER, + syslog.LOG_MAIL: SyslogFacility.MAIL, + syslog.LOG_DAEMON: SyslogFacility.DAEMON, + syslog.LOG_AUTH: SyslogFacility.AUTH, + syslog.LOG_SYSLOG: SyslogFacility.SYSLOG, + syslog.LOG_LPR: SyslogFacility.LPR, + syslog.LOG_NEWS: SyslogFacility.NEWS, + syslog.LOG_UUCP: SyslogFacility.UUCP, + syslog.LOG_CRON: SyslogFacility.CRON, + syslog.LOG_LOCAL0: SyslogFacility.LOCAL0, + syslog.LOG_LOCAL1: SyslogFacility.LOCAL1, + syslog.LOG_LOCAL2: SyslogFacility.LOCAL2, + syslog.LOG_LOCAL3: SyslogFacility.LOCAL3, + syslog.LOG_LOCAL4: SyslogFacility.LOCAL4, + syslog.LOG_LOCAL5: SyslogFacility.LOCAL5, + syslog.LOG_LOCAL6: SyslogFacility.LOCAL6, + syslog.LOG_LOCAL7: SyslogFacility.LOCAL7, +} + +# Used as a lookup when handling the Apprise -> Syslog Mapping +SYSLOG_PUBLISH_MAP = { + NotifyType.INFO: syslog.LOG_INFO, + NotifyType.SUCCESS: syslog.LOG_NOTICE, + NotifyType.FAILURE: syslog.LOG_CRIT, + NotifyType.WARNING: syslog.LOG_WARNING, +} + + +class NotifyRSyslog(NotifyBase): + """ + A wrapper for Remote Syslog Notifications + """ + + # The default descriptive name associated with the Notification + service_name = 'Remote Syslog' + + # The services URL + service_url = 'https://tools.ietf.org/html/rfc5424' + + # The default protocol + protocol = 'rsyslog' + + # A URL that takes you to the setup/help of the specific protocol + setup_url = 'https://github.com/caronc/apprise/wiki/Notify_rsyslog' + + # Disable throttle rate for RSyslog requests + request_rate_per_sec = 0 + + # Define object templates + templates = ( + '{schema}://{host}', + '{schema}://{host}:{port}', + '{schema}://{host}/{facility}', + '{schema}://{host}:{port}/{facility}', + ) + + # Define our template tokens + template_tokens = dict(NotifyBase.template_tokens, **{ + 'facility': { + 'name': _('Facility'), + 'type': 'choice:string', + 'values': [k for k in SYSLOG_FACILITY_MAP.keys()], + 'default': SyslogFacility.USER, + 'required': True, + }, + 'host': { + 'name': _('Hostname'), + 'type': 'string', + 'required': True, + }, + 'port': { + 'name': _('Port'), + 'type': 'int', + 'min': 1, + 'max': 65535, + 'default': 514, + }, + }) + + # Define our template arguments + template_args = dict(NotifyBase.template_args, **{ + 'facility': { + # We map back to the same element defined in template_tokens + 'alias_of': 'facility', + }, + 'logpid': { + 'name': _('Log PID'), + 'type': 'bool', + 'default': True, + 'map_to': 'log_pid', + }, + }) + + def __init__(self, facility=None, log_pid=True, **kwargs): + """ + Initialize RSyslog Object + """ + super().__init__(**kwargs) + + if facility: + try: + self.facility = SYSLOG_FACILITY_MAP[facility] + + except KeyError: + msg = 'An invalid syslog facility ' \ + '({}) was specified.'.format(facility) + self.logger.warning(msg) + raise TypeError(msg) + + else: + self.facility = \ + SYSLOG_FACILITY_MAP[ + self.template_tokens['facility']['default']] + + # Include PID with each message. + self.log_pid = log_pid + + return + + def send(self, body, title='', notify_type=NotifyType.INFO, **kwargs): + """ + Perform RSyslog Notification + """ + + if title: + # Format title + body = '{}: {}'.format(title, body) + + # Always call throttle before any remote server i/o is made + self.throttle() + host = self.host + port = self.port if self.port \ + else self.template_tokens['port']['default'] + + if self.log_pid: + payload = '<%d>- %d - %s' % ( + SYSLOG_PUBLISH_MAP[notify_type] + self.facility * 8, + os.getpid(), body) + + else: + payload = '<%d>- %s' % ( + SYSLOG_PUBLISH_MAP[notify_type] + self.facility * 8, body) + + # send UDP packet to upstream server + self.logger.debug( + 'RSyslog Host: %s:%d/%s', + host, port, SYSLOG_FACILITY_RMAP[self.facility]) + self.logger.debug('RSyslog Payload: %s' % str(payload)) + + # our sent bytes + sent = 0 + + try: + sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + sock.settimeout(self.socket_connect_timeout) + sent = sock.sendto(payload.encode('utf-8'), (host, port)) + sock.close() + + except socket.gaierror as e: + self.logger.warning( + 'A connection error occurred sending RSyslog ' + 'notification to %s:%d/%s', host, port, + SYSLOG_FACILITY_RMAP[self.facility] + ) + self.logger.debug('Socket Exception: %s' % str(e)) + return False + + except socket.timeout as e: + self.logger.warning( + 'A connection timeout occurred sending RSyslog ' + 'notification to %s:%d/%s', host, port, + SYSLOG_FACILITY_RMAP[self.facility] + ) + self.logger.debug('Socket Exception: %s' % str(e)) + return False + + if sent < len(payload): + self.logger.warning( + 'RSyslog sent %d byte(s) but intended to send %d byte(s)', + sent, len(payload)) + return False + + self.logger.info('Sent RSyslog notification.') + + return True + + def url(self, privacy=False, *args, **kwargs): + """ + Returns the URL built dynamically based on specified arguments. + """ + + # Define any URL parameters + params = { + 'logpid': 'yes' if self.log_pid else 'no', + } + + # Extend our parameters + params.update(self.url_parameters(privacy=privacy, *args, **kwargs)) + + return '{schema}://{hostname}{port}/{facility}/?{params}'.format( + schema=self.protocol, + hostname=NotifyRSyslog.quote(self.host, safe=''), + port='' if self.port is None + or self.port == self.template_tokens['port']['default'] + else ':{}'.format(self.port), + facility=self.template_tokens['facility']['default'] + if self.facility not in SYSLOG_FACILITY_RMAP + else SYSLOG_FACILITY_RMAP[self.facility], + params=NotifyRSyslog.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 + + tokens = [] + + # Get our path values + tokens.extend(NotifyRSyslog.split_path(results['fullpath'])) + + # Initialization + facility = None + + if tokens: + # Store the last entry as the facility + facility = tokens[-1].lower() + + # However if specified on the URL, that will over-ride what was + # identified + if 'facility' in results['qsd'] and len(results['qsd']['facility']): + facility = results['qsd']['facility'].lower() + + if facility and facility not in SYSLOG_FACILITY_MAP: + # Find first match; if no match is found we set the result + # to the matching key. This allows us to throw a TypeError + # during the __init__() call. The benifit of doing this + # check here is if we do have a valid match, we can support + # short form matches like 'u' which will match against user + facility = next((f for f in SYSLOG_FACILITY_MAP.keys() + if f.startswith(facility)), facility) + + # Save facility if set + if facility: + results['facility'] = facility + + # Include PID as part of the message logged + results['log_pid'] = parse_bool( + results['qsd'].get( + 'logpid', + NotifyRSyslog.template_args['logpid']['default'])) + + return results diff --git a/lib/apprise/plugins/NotifyReddit.py b/lib/apprise/plugins/NotifyReddit.py index 4e54109c..b25e76d0 100644 --- a/lib/apprise/plugins/NotifyReddit.py +++ b/lib/apprise/plugins/NotifyReddit.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -# BSD 3-Clause License +# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2023, Chris Caron @@ -14,10 +14,6 @@ # 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 @@ -30,7 +26,6 @@ # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. -# # 1. Visit https://www.reddit.com/prefs/apps and scroll to the bottom # 2. Click on the button that reads 'are you a developer? create an app...' # 3. Set the mode to `script`, @@ -56,6 +51,7 @@ import requests from json import loads from datetime import timedelta from datetime import datetime +from datetime import timezone from .NotifyBase import NotifyBase from ..URLBase import PrivacyMode @@ -133,12 +129,6 @@ class NotifyReddit(NotifyBase): # still allow to make. request_rate_per_sec = 0 - # For Tracking Purposes - ratelimit_reset = datetime.utcnow() - - # Default to 1.0 - ratelimit_remaining = 1.0 - # Taken right from google.auth.helpers: clock_skew = timedelta(seconds=10) @@ -185,6 +175,7 @@ class NotifyReddit(NotifyBase): 'targets': { 'name': _('Targets'), 'type': 'list:string', + 'required': True, }, }) @@ -275,7 +266,7 @@ class NotifyReddit(NotifyBase): # Our keys we build using the provided content self.__refresh_token = None self.__access_token = None - self.__access_token_expiry = datetime.utcnow() + self.__access_token_expiry = datetime.now(timezone.utc) self.kind = kind.strip().lower() \ if isinstance(kind, str) \ @@ -324,6 +315,13 @@ class NotifyReddit(NotifyBase): if not self.subreddits: self.logger.warning( 'No subreddits were identified to be notified') + + # For Rate Limit Tracking Purposes + self.ratelimit_reset = datetime.now(timezone.utc).replace(tzinfo=None) + + # Default to 1.0 + self.ratelimit_remaining = 1.0 + return def url(self, privacy=False, *args, **kwargs): @@ -367,6 +365,12 @@ class NotifyReddit(NotifyBase): params=NotifyReddit.urlencode(params), ) + def __len__(self): + """ + Returns the number of targets associated with this notification + """ + return len(self.subreddits) + def login(self): """ A simple wrapper to authenticate with the Reddit Server @@ -411,10 +415,10 @@ class NotifyReddit(NotifyBase): if 'expires_in' in response: delta = timedelta(seconds=int(response['expires_in'])) self.__access_token_expiry = \ - delta + datetime.utcnow() - self.clock_skew + delta + datetime.now(timezone.utc) - self.clock_skew else: self.__access_token_expiry = self.access_token_lifetime_sec + \ - datetime.utcnow() - self.clock_skew + datetime.now(timezone.utc) - self.clock_skew # The Refresh Token self.__refresh_token = response.get( @@ -538,10 +542,10 @@ class NotifyReddit(NotifyBase): # Determine how long we should wait for or if we should wait at # all. This isn't fool-proof because we can't be sure the client # time (calling this script) is completely synced up with the - # Gitter server. One would hope we're on NTP and our clocks are + # Reddit server. One would hope we're on NTP and our clocks are # the same allowing this to role smoothly: - now = datetime.utcnow() + now = datetime.now(timezone.utc).replace(tzinfo=None) if now < self.ratelimit_reset: # We need to throttle for the difference in seconds wait = abs( @@ -665,8 +669,9 @@ class NotifyReddit(NotifyBase): self.ratelimit_remaining = \ float(r.headers.get( 'X-RateLimit-Remaining')) - self.ratelimit_reset = datetime.utcfromtimestamp( - int(r.headers.get('X-RateLimit-Reset'))) + self.ratelimit_reset = datetime.fromtimestamp( + int(r.headers.get('X-RateLimit-Reset')), timezone.utc + ).replace(tzinfo=None) except (TypeError, ValueError): # This is returned if we could not retrieve this information diff --git a/lib/apprise/plugins/NotifyRocketChat.py b/lib/apprise/plugins/NotifyRocketChat.py index c8f5e965..6384386e 100644 --- a/lib/apprise/plugins/NotifyRocketChat.py +++ b/lib/apprise/plugins/NotifyRocketChat.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -# BSD 3-Clause License +# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2023, Chris Caron @@ -14,10 +14,6 @@ # 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 @@ -348,6 +344,13 @@ class NotifyRocketChat(NotifyBase): params=NotifyRocketChat.urlencode(params), ) + def __len__(self): + """ + Returns the number of targets associated with this notification + """ + targets = len(self.channels) + len(self.rooms) + len(self.users) + return targets if targets > 0 else 1 + def send(self, body, title='', notify_type=NotifyType.INFO, **kwargs): """ wrapper to _send since we can alert more then one channel diff --git a/lib/apprise/plugins/NotifyRyver.py b/lib/apprise/plugins/NotifyRyver.py index b8b34a3c..70f2fa43 100644 --- a/lib/apprise/plugins/NotifyRyver.py +++ b/lib/apprise/plugins/NotifyRyver.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -# BSD 3-Clause License +# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2023, Chris Caron @@ -14,10 +14,6 @@ # 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 @@ -91,7 +87,7 @@ class NotifyRyver(NotifyBase): # Define object templates templates = ( '{schema}://{organization}/{token}', - '{schema}://{user}@{organization}/{token}', + '{schema}://{botname}@{organization}/{token}', ) # Define our template tokens @@ -109,9 +105,10 @@ class NotifyRyver(NotifyBase): 'private': True, 'regex': (r'^[A-Z0-9]{15}$', 'i'), }, - 'user': { + 'botname': { 'name': _('Bot Name'), 'type': 'string', + 'map_to': 'user', }, }) diff --git a/lib/apprise/plugins/NotifySES.py b/lib/apprise/plugins/NotifySES.py index e58893bd..37a0342a 100644 --- a/lib/apprise/plugins/NotifySES.py +++ b/lib/apprise/plugins/NotifySES.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -# BSD 3-Clause License +# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2023, Chris Caron @@ -14,10 +14,6 @@ # 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 @@ -89,6 +85,7 @@ import base64 import requests from hashlib import sha256 from datetime import datetime +from datetime import timezone from collections import OrderedDict from xml.etree import ElementTree from email.mime.text import MIMEText @@ -135,6 +132,9 @@ class NotifySES(NotifyBase): # A URL that takes you to the setup/help of the specific protocol setup_url = 'https://github.com/caronc/apprise/wiki/Notify_ses' + # Support attachments + attachment_support = True + # AWS is pretty good for handling data load so request limits # can occur in much shorter bursts request_rate_per_sec = 2.5 @@ -156,6 +156,7 @@ class NotifySES(NotifyBase): 'name': _('From Email'), 'type': 'string', 'map_to': 'from_addr', + 'required': True, }, 'access_key_id': { 'name': _('Access Key ID'), @@ -173,6 +174,7 @@ class NotifySES(NotifyBase): 'name': _('Region'), 'type': 'string', 'regex': (r'^[a-z]{2}-[a-z-]+?-[0-9]+$', 'i'), + 'required': True, 'map_to': 'region_name', }, 'targets': { @@ -424,7 +426,8 @@ class NotifySES(NotifyBase): content = MIMEText(body, 'plain', 'utf-8') # Create a Multipart container if there is an attachment - base = MIMEMultipart() if attach else content + base = MIMEMultipart() \ + if attach and self.attachment_support else content # TODO: Deduplicate with `NotifyEmail`? base['Subject'] = Header(title, 'utf-8') @@ -436,10 +439,11 @@ class NotifySES(NotifyBase): base['Reply-To'] = formataddr(reply_to, charset='utf-8') base['Cc'] = ','.join(cc) base['Date'] = \ - datetime.utcnow().strftime("%a, %d %b %Y %H:%M:%S +0000") + datetime.now( + timezone.utc).strftime("%a, %d %b %Y %H:%M:%S +0000") base['X-Application'] = self.app_id - if attach: + if attach and self.attachment_support: # First attach our body to our content as the first element base.attach(content) @@ -585,7 +589,7 @@ class NotifySES(NotifyBase): } # Get a reference time (used for header construction) - reference = datetime.utcnow() + reference = datetime.now(timezone.utc) # Provide Content-Length headers['Content-Length'] = str(len(payload)) @@ -816,6 +820,13 @@ class NotifySES(NotifyBase): params=NotifySES.urlencode(params), ) + def __len__(self): + """ + Returns the number of targets associated with this notification + """ + targets = len(self.targets) + return targets if targets > 0 else 1 + @staticmethod def parse_url(url): """ diff --git a/lib/apprise/plugins/NotifySMSEagle.py b/lib/apprise/plugins/NotifySMSEagle.py index 50b44cf3..3db131fb 100644 --- a/lib/apprise/plugins/NotifySMSEagle.py +++ b/lib/apprise/plugins/NotifySMSEagle.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -# BSD 3-Clause License +# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2023, Chris Caron @@ -14,10 +14,6 @@ # 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 @@ -73,6 +69,22 @@ SMSEAGLE_PRIORITY_MAP = { } +class SMSEagleCategory: + """ + We define the different category types that we can notify via SMS Eagle + """ + PHONE = 'phone' + GROUP = 'group' + CONTACT = 'contact' + + +SMSEAGLE_CATEGORIES = ( + SMSEagleCategory.PHONE, + SMSEagleCategory.GROUP, + SMSEagleCategory.CONTACT, +) + + class NotifySMSEagle(NotifyBase): """ A wrapper for SMSEagle Notifications @@ -96,6 +108,9 @@ class NotifySMSEagle(NotifyBase): # The path we send our notification to notify_path = '/jsonrpc/sms' + # Support attachments + attachment_support = True + # The maxumum length of the text message # The actual limit is 160 but SMSEagle looks after the handling # of large messages in it's upstream service @@ -129,6 +144,7 @@ class NotifySMSEagle(NotifyBase): 'token': { 'name': _('Access Token'), 'type': 'string', + 'required': True, }, 'target_phone': { 'name': _('Target Phone No'), @@ -154,6 +170,7 @@ class NotifySMSEagle(NotifyBase): 'targets': { 'name': _('Targets'), 'type': 'list:string', + 'required': True, } }) @@ -322,7 +339,7 @@ class NotifySMSEagle(NotifyBase): has_error = False attachments = [] - if attach: + if attach and self.attachment_support: for attachment in attach: # Perform some simple error checking if not attachment: @@ -403,15 +420,15 @@ class NotifySMSEagle(NotifyBase): batch_size = 1 if not self.batch else self.default_batch_size notify_by = { - 'phone': { + SMSEagleCategory.PHONE: { "method": "sms.send_sms", 'target': 'to', }, - 'group': { + SMSEagleCategory.GROUP: { "method": "sms.send_togroup", 'target': 'groupname', }, - 'contact': { + SMSEagleCategory.CONTACT: { "method": "sms.send_tocontact", 'target': 'contactname', }, @@ -420,7 +437,7 @@ class NotifySMSEagle(NotifyBase): # categories separated into a tuple since notify_by.keys() # returns an unpredicable list in Python 2.7 which causes # tests to fail every so often - for category in ('phone', 'group', 'contact'): + for category in SMSEAGLE_CATEGORIES: # Create a copy of our template payload = { 'method': notify_by[category]['method'], @@ -596,6 +613,28 @@ class NotifySMSEagle(NotifyBase): params=NotifySMSEagle.urlencode(params), ) + def __len__(self): + """ + Returns the number of targets associated with this notification + """ + # + # Factor batch into calculation + # + batch_size = 1 if not self.batch else self.default_batch_size + if batch_size > 1: + # Batches can only be sent by group (you can't combine groups into + # a single batch) + total_targets = 0 + for c in SMSEAGLE_CATEGORIES: + targets = len(getattr(self, f'target_{c}s')) + total_targets += int(targets / batch_size) + \ + (1 if targets % batch_size else 0) + return total_targets + + # Normal batch count; just count the targets + return len(self.target_phones) + len(self.target_contacts) + \ + len(self.target_groups) + @staticmethod def parse_url(url): """ diff --git a/lib/apprise/plugins/NotifySMTP2Go.py b/lib/apprise/plugins/NotifySMTP2Go.py index 3c672694..45f6615c 100644 --- a/lib/apprise/plugins/NotifySMTP2Go.py +++ b/lib/apprise/plugins/NotifySMTP2Go.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -# BSD 3-Clause License +# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2023, Chris Caron @@ -14,10 +14,6 @@ # 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 @@ -91,6 +87,9 @@ class NotifySMTP2Go(NotifyBase): # Notify URL notify_url = 'https://api.smtp2go.com/v3/email/send' + # Support attachments + attachment_support = True + # Default Notify Format notify_format = NotifyFormat.HTML @@ -294,8 +293,8 @@ class NotifySMTP2Go(NotifyBase): # Track our potential attachments attachments = [] - if attach: - for idx, attachment in enumerate(attach): + if attach and self.attachment_support: + for attachment in attach: # Perform some simple error checking if not attachment: # We could not access the attachment @@ -513,6 +512,21 @@ class NotifySMTP2Go(NotifyBase): safe='') for e in self.targets]), params=NotifySMTP2Go.urlencode(params)) + def __len__(self): + """ + Returns the number of targets associated with this notification + """ + # + # Factor batch into calculation + # + batch_size = 1 if not self.batch else self.default_batch_size + targets = len(self.targets) + if batch_size > 1: + targets = int(targets / batch_size) + \ + (1 if targets % batch_size else 0) + + return targets if targets > 0 else 1 + @staticmethod def parse_url(url): """ diff --git a/lib/apprise/plugins/NotifySNS.py b/lib/apprise/plugins/NotifySNS.py index 28eea46b..5edac727 100644 --- a/lib/apprise/plugins/NotifySNS.py +++ b/lib/apprise/plugins/NotifySNS.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -# BSD 3-Clause License +# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2023, Chris Caron @@ -14,10 +14,6 @@ # 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 @@ -35,6 +31,7 @@ import hmac import requests from hashlib import sha256 from datetime import datetime +from datetime import timezone from collections import OrderedDict from xml.etree import ElementTree from itertools import chain @@ -102,7 +99,7 @@ class NotifySNS(NotifyBase): # Define object templates templates = ( - '{schema}://{access_key_id}/{secret_access_key}{region}/{targets}', + '{schema}://{access_key_id}/{secret_access_key}/{region}/{targets}', ) # Define our template tokens @@ -124,6 +121,7 @@ class NotifySNS(NotifyBase): 'type': 'string', 'required': True, 'regex': (r'^[a-z]{2}-[a-z-]+?-[0-9]+$', 'i'), + 'required': True, 'map_to': 'region_name', }, 'target_phone_no': { @@ -142,6 +140,7 @@ class NotifySNS(NotifyBase): 'targets': { 'name': _('Targets'), 'type': 'list:string', + 'required': True, }, }) @@ -396,7 +395,7 @@ class NotifySNS(NotifyBase): } # Get a reference time (used for header construction) - reference = datetime.utcnow() + reference = datetime.now(timezone.utc) # Provide Content-Length headers['Content-Length'] = str(len(payload)) @@ -600,6 +599,12 @@ class NotifySNS(NotifyBase): params=NotifySNS.urlencode(params), ) + def __len__(self): + """ + Returns the number of targets associated with this notification + """ + return len(self.phone) + len(self.topics) + @staticmethod def parse_url(url): """ diff --git a/lib/apprise/plugins/NotifySendGrid.py b/lib/apprise/plugins/NotifySendGrid.py index d811fa1d..b7f4a8a6 100644 --- a/lib/apprise/plugins/NotifySendGrid.py +++ b/lib/apprise/plugins/NotifySendGrid.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -# BSD 3-Clause License +# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2023, Chris Caron @@ -14,10 +14,6 @@ # 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 @@ -30,7 +26,6 @@ # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. -# # You will need an API Key for this plugin to work. # From the Settings -> API Keys you can click "Create API Key" if you don't # have one already. The key must have at least the "Mail Send" permission @@ -287,6 +282,12 @@ class NotifySendGrid(NotifyBase): params=NotifySendGrid.urlencode(params), ) + def __len__(self): + """ + Returns the number of targets associated with this notification + """ + return len(self.targets) + def send(self, body, title='', notify_type=NotifyType.INFO, **kwargs): """ Perform SendGrid Notification diff --git a/lib/apprise/plugins/NotifyServerChan.py b/lib/apprise/plugins/NotifyServerChan.py index 6fa8c557..87a294a3 100644 --- a/lib/apprise/plugins/NotifyServerChan.py +++ b/lib/apprise/plugins/NotifyServerChan.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -# BSD 3-Clause License +# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2023, Chris Caron @@ -14,10 +14,6 @@ # 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 @@ -68,7 +64,7 @@ class NotifyServerChan(NotifyBase): # Define object templates templates = ( - '{schema}://{token}/', + '{schema}://{token}', ) # Define our template tokens diff --git a/lib/apprise/plugins/NotifySignalAPI.py b/lib/apprise/plugins/NotifySignalAPI.py index 46708d19..a2a31de1 100644 --- a/lib/apprise/plugins/NotifySignalAPI.py +++ b/lib/apprise/plugins/NotifySignalAPI.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -# BSD 3-Clause License +# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2023, Chris Caron @@ -14,10 +14,6 @@ # 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 @@ -68,6 +64,9 @@ class NotifySignalAPI(NotifyBase): # A URL that takes you to the setup/help of the specific protocol setup_url = 'https://github.com/caronc/apprise/wiki/Notify_signal' + # Support attachments + attachment_support = True + # The maximum targets to include when doing batch transfers default_batch_size = 10 @@ -234,7 +233,7 @@ class NotifySignalAPI(NotifyBase): has_error = False attachments = [] - if attach: + if attach and self.attachment_support: for attachment in attach: # Perform some simple error checking if not attachment: @@ -281,7 +280,7 @@ class NotifySignalAPI(NotifyBase): payload = { 'message': "{}{}".format( '' if not self.status else '{} '.format( - self.asset.ascii(notify_type)), body), + self.asset.ascii(notify_type)), body).rstrip(), "number": self.source, "recipients": [] } @@ -431,6 +430,21 @@ class NotifySignalAPI(NotifyBase): params=NotifySignalAPI.urlencode(params), ) + def __len__(self): + """ + Returns the number of targets associated with this notification + """ + # + # Factor batch into calculation + # + batch_size = 1 if not self.batch else self.default_batch_size + targets = len(self.targets) + if batch_size > 1: + targets = int(targets / batch_size) + \ + (1 if targets % batch_size else 0) + + return targets + @staticmethod def parse_url(url): """ diff --git a/lib/apprise/plugins/NotifySimplePush.py b/lib/apprise/plugins/NotifySimplePush.py index 25066067..d6bd2ab6 100644 --- a/lib/apprise/plugins/NotifySimplePush.py +++ b/lib/apprise/plugins/NotifySimplePush.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -# BSD 3-Clause License +# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2023, Chris Caron @@ -14,10 +14,6 @@ # 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 @@ -109,12 +105,12 @@ class NotifySimplePush(NotifyBase): # Used for encrypted logins 'password': { - 'name': _('Encrypted Password'), + 'name': _('Password'), 'type': 'string', 'private': True, }, 'salt': { - 'name': _('Encrypted Salt'), + 'name': _('Salt'), 'type': 'string', 'private': True, 'map_to': 'user', diff --git a/lib/apprise/plugins/NotifySinch.py b/lib/apprise/plugins/NotifySinch.py index c4d400b0..b2c5683f 100644 --- a/lib/apprise/plugins/NotifySinch.py +++ b/lib/apprise/plugins/NotifySinch.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -# BSD 3-Clause License +# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2023, Chris Caron @@ -14,10 +14,6 @@ # 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 @@ -408,6 +404,13 @@ class NotifySinch(NotifyBase): [NotifySinch.quote(x, safe='') for x in self.targets]), params=NotifySinch.urlencode(params)) + def __len__(self): + """ + Returns the number of targets associated with this notification + """ + targets = len(self.targets) + return targets if targets > 0 else 1 + @staticmethod def parse_url(url): """ diff --git a/lib/apprise/plugins/NotifySlack.py b/lib/apprise/plugins/NotifySlack.py index 1a437ffa..bbd2bf24 100644 --- a/lib/apprise/plugins/NotifySlack.py +++ b/lib/apprise/plugins/NotifySlack.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -# BSD 3-Clause License +# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2023, Chris Caron @@ -14,10 +14,6 @@ # 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 @@ -143,6 +139,10 @@ class NotifySlack(NotifyBase): # A URL that takes you to the setup/help of the specific protocol setup_url = 'https://github.com/caronc/apprise/wiki/Notify_slack' + # Support attachments + attachment_support = True + + # The maximum targets to include when doing batch transfers # Slack Webhook URL webhook_url = 'https://hooks.slack.com/services' @@ -165,10 +165,10 @@ class NotifySlack(NotifyBase): # Define object templates templates = ( # Webhook - '{schema}://{token_a}/{token_b}{token_c}', + '{schema}://{token_a}/{token_b}/{token_c}', '{schema}://{botname}@{token_a}/{token_b}{token_c}', - '{schema}://{token_a}/{token_b}{token_c}/{targets}', - '{schema}://{botname}@{token_a}/{token_b}{token_c}/{targets}', + '{schema}://{token_a}/{token_b}/{token_c}/{targets}', + '{schema}://{botname}@{token_a}/{token_b}/{token_c}/{targets}', # Bot '{schema}://{access_token}/', @@ -198,7 +198,6 @@ class NotifySlack(NotifyBase): 'name': _('Token A'), 'type': 'string', 'private': True, - 'required': True, 'regex': (r'^[A-Z0-9]+$', 'i'), }, # Token required as part of the Webhook request @@ -207,7 +206,6 @@ class NotifySlack(NotifyBase): 'name': _('Token B'), 'type': 'string', 'private': True, - 'required': True, 'regex': (r'^[A-Z0-9]+$', 'i'), }, # Token required as part of the Webhook request @@ -216,7 +214,6 @@ class NotifySlack(NotifyBase): 'name': _('Token C'), 'type': 'string', 'private': True, - 'required': True, 'regex': (r'^[A-Za-z0-9]+$', 'i'), }, 'target_encoded_id': { @@ -354,6 +351,13 @@ class NotifySlack(NotifyBase): r'>': '>', } + # To notify a channel, one uses + self._re_channel_support = re.compile( + r'(?P(?:<|\<)?[ \t]*' + r'!(?P[^| \n]+)' + r'(?:[ \t]*\|[ \t]*(?:(?P[^\n]+?)[ \t]*)?(?:>|\>)' + r'|(?:>|\>)))', re.IGNORECASE) + # The markdown in slack isn't [desc](url), it's # # To accomodate this, we need to ensure we don't escape URLs that match @@ -455,6 +459,21 @@ class NotifySlack(NotifyBase): lambda x: self._re_formatting_map[x.group()], body, ) + # Support , entries + for match in self._re_channel_support.findall(body): + # Swap back any ampersands previously updaated + channel = match[1].strip() + desc = match[2].strip() + + # Update our string + body = re.sub( + re.escape(match[0]), + ''.format( + channel=channel, desc=desc) + if desc else ''.format(channel=channel), + body, + re.IGNORECASE) + # Support , entries for match in self._re_url_support.findall(body): # Swap back any ampersands previously updaated @@ -503,7 +522,8 @@ class NotifySlack(NotifyBase): # Include the footer only if specified to do so payload['attachments'][0]['footer'] = self.app_id - if attach and self.mode is SlackMode.WEBHOOK: + if attach and self.attachment_support \ + and self.mode is SlackMode.WEBHOOK: # Be friendly; let the user know why they can't send their # attachments if using the Webhook mode self.logger.warning( @@ -581,7 +601,8 @@ class NotifySlack(NotifyBase): ' to {}'.format(channel) if channel is not None else '')) - if attach and self.mode is SlackMode.BOT and attach_channel_list: + if attach and self.attachment_support and \ + self.mode is SlackMode.BOT and attach_channel_list: # Send our attachments (can only be done in bot mode) for attachment in attach: @@ -993,6 +1014,12 @@ class NotifySlack(NotifyBase): params=NotifySlack.urlencode(params), ) + def __len__(self): + """ + Returns the number of targets associated with this notification + """ + return len(self.channels) + @staticmethod def parse_url(url): """ diff --git a/lib/apprise/plugins/NotifySparkPost.py b/lib/apprise/plugins/NotifySparkPost.py index bf83a9f5..282f5509 100644 --- a/lib/apprise/plugins/NotifySparkPost.py +++ b/lib/apprise/plugins/NotifySparkPost.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -# BSD 3-Clause License +# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2023, Chris Caron @@ -14,10 +14,6 @@ # 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 @@ -118,6 +114,9 @@ class NotifySparkPost(NotifyBase): # The services URL service_url = 'https://sparkpost.com/' + # Support attachments + attachment_support = True + # All notification requests are secure secure_protocol = 'sparkpost' @@ -225,7 +224,7 @@ class NotifySparkPost(NotifyBase): } def __init__(self, apikey, targets, cc=None, bcc=None, from_name=None, - region_name=None, headers=None, tokens=None, batch=False, + region_name=None, headers=None, tokens=None, batch=None, **kwargs): """ Initialize SparkPost Object @@ -296,7 +295,8 @@ class NotifySparkPost(NotifyBase): self.tokens.update(tokens) # Prepare Batch Mode Flag - self.batch = batch + self.batch = self.template_args['batch']['default'] \ + if batch is None else batch if targets: # Validate recipients (to:) and drop bad ones: @@ -542,7 +542,7 @@ class NotifySparkPost(NotifyBase): else: payload['content']['text'] = body - if attach: + if attach and self.attachment_support: # Prepare ourselves an attachment object payload['content']['attachments'] = [] @@ -722,6 +722,21 @@ class NotifySparkPost(NotifyBase): safe='') for e in self.targets]), params=NotifySparkPost.urlencode(params)) + def __len__(self): + """ + Returns the number of targets associated with this notification + """ + # + # Factor batch into calculation + # + batch_size = 1 if not self.batch else self.default_batch_size + targets = len(self.targets) + if batch_size > 1: + targets = int(targets / batch_size) + \ + (1 if targets % batch_size else 0) + + return targets if targets > 0 else 1 + @staticmethod def parse_url(url): """ diff --git a/lib/apprise/plugins/NotifySpontit.py b/lib/apprise/plugins/NotifySpontit.py index 4df8b538..4705fc05 100644 --- a/lib/apprise/plugins/NotifySpontit.py +++ b/lib/apprise/plugins/NotifySpontit.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -# BSD 3-Clause License +# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2023, Chris Caron @@ -14,10 +14,6 @@ # 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 @@ -135,7 +131,6 @@ class NotifySpontit(NotifyBase): 'targets': { 'name': _('Targets'), 'type': 'list:string', - 'required': True, }, }) @@ -350,6 +345,13 @@ class NotifySpontit(NotifyBase): [NotifySpontit.quote(x, safe='') for x in self.targets]), params=NotifySpontit.urlencode(params)) + def __len__(self): + """ + Returns the number of targets associated with this notification + """ + targets = len(self.targets) + return targets if targets > 0 else 1 + @staticmethod def parse_url(url): """ diff --git a/lib/apprise/plugins/NotifyStreamlabs.py b/lib/apprise/plugins/NotifyStreamlabs.py index 3489519a..56b577e4 100644 --- a/lib/apprise/plugins/NotifyStreamlabs.py +++ b/lib/apprise/plugins/NotifyStreamlabs.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -# BSD 3-Clause License +# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2023, Chris Caron @@ -14,10 +14,6 @@ # 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 @@ -277,8 +273,7 @@ class NotifyStreamlabs(NotifyBase): return - def send(self, body, title='', notify_type=NotifyType.INFO, attach=None, - **kwargs): + def send(self, body, title='', notify_type=NotifyType.INFO, **kwargs): """ Perform Streamlabs notification call (either donation or alert) """ diff --git a/lib/apprise/plugins/NotifySyslog.py b/lib/apprise/plugins/NotifySyslog.py index 433aab9c..3ff1f257 100644 --- a/lib/apprise/plugins/NotifySyslog.py +++ b/lib/apprise/plugins/NotifySyslog.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -# BSD 3-Clause License +# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2023, Chris Caron @@ -14,10 +14,6 @@ # 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 @@ -30,14 +26,11 @@ # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. -import os import syslog -import socket from .NotifyBase import NotifyBase from ..common import NotifyType from ..utils import parse_bool -from ..utils import is_hostname from ..AppriseLocale import gettext_lazy as _ @@ -107,20 +100,13 @@ SYSLOG_FACILITY_RMAP = { syslog.LOG_LOCAL7: SyslogFacility.LOCAL7, } - -class SyslogMode: - # A local query - LOCAL = "local" - - # A remote query - REMOTE = "remote" - - -# webhook modes are placed ito this list for validation purposes -SYSLOG_MODES = ( - SyslogMode.LOCAL, - SyslogMode.REMOTE, -) +# Used as a lookup when handling the Apprise -> Syslog Mapping +SYSLOG_PUBLISH_MAP = { + NotifyType.INFO: syslog.LOG_INFO, + NotifyType.SUCCESS: syslog.LOG_NOTICE, + NotifyType.FAILURE: syslog.LOG_CRIT, + NotifyType.WARNING: syslog.LOG_WARNING, +} class NotifySyslog(NotifyBase): @@ -134,8 +120,8 @@ class NotifySyslog(NotifyBase): # The services URL service_url = 'https://tools.ietf.org/html/rfc5424' - # The default secure protocol - secure_protocol = 'syslog' + # The default protocol + protocol = 'syslog' # A URL that takes you to the setup/help of the specific protocol setup_url = 'https://github.com/caronc/apprise/wiki/Notify_syslog' @@ -148,10 +134,6 @@ class NotifySyslog(NotifyBase): templates = ( '{schema}://', '{schema}://{facility}', - '{schema}://{host}', - '{schema}://{host}:{port}', - '{schema}://{host}/{facility}', - '{schema}://{host}:{port}/{facility}', ) # Define our template tokens @@ -162,18 +144,6 @@ class NotifySyslog(NotifyBase): 'values': [k for k in SYSLOG_FACILITY_MAP.keys()], 'default': SyslogFacility.USER, }, - 'host': { - 'name': _('Hostname'), - 'type': 'string', - 'required': True, - }, - 'port': { - 'name': _('Port'), - 'type': 'int', - 'min': 1, - 'max': 65535, - 'default': 514, - }, }) # Define our template arguments @@ -182,12 +152,6 @@ class NotifySyslog(NotifyBase): # We map back to the same element defined in template_tokens 'alias_of': 'facility', }, - 'mode': { - 'name': _('Syslog Mode'), - 'type': 'choice:string', - 'values': SYSLOG_MODES, - 'default': SyslogMode.LOCAL, - }, 'logpid': { 'name': _('Log PID'), 'type': 'bool', @@ -202,8 +166,8 @@ class NotifySyslog(NotifyBase): }, }) - def __init__(self, facility=None, mode=None, log_pid=True, - log_perror=False, **kwargs): + def __init__(self, facility=None, log_pid=True, log_perror=False, + **kwargs): """ Initialize Syslog Object """ @@ -223,14 +187,6 @@ class NotifySyslog(NotifyBase): SYSLOG_FACILITY_MAP[ self.template_tokens['facility']['default']] - self.mode = self.template_args['mode']['default'] \ - if not isinstance(mode, str) else mode.lower() - - if self.mode not in SYSLOG_MODES: - msg = 'The mode specified ({}) is invalid.'.format(mode) - self.logger.warning(msg) - raise TypeError(msg) - # Logging Options self.logoptions = 0 @@ -249,7 +205,7 @@ class NotifySyslog(NotifyBase): if log_perror: self.logoptions |= syslog.LOG_PERROR - # Initialize our loggig + # Initialize our logging syslog.openlog( self.app_id, logoption=self.logoptions, facility=self.facility) return @@ -259,7 +215,7 @@ class NotifySyslog(NotifyBase): Perform Syslog Notification """ - _pmap = { + SYSLOG_PUBLISH_MAP = { NotifyType.INFO: syslog.LOG_INFO, NotifyType.SUCCESS: syslog.LOG_NOTICE, NotifyType.FAILURE: syslog.LOG_CRIT, @@ -272,70 +228,17 @@ class NotifySyslog(NotifyBase): # Always call throttle before any remote server i/o is made self.throttle() - if self.mode == SyslogMode.LOCAL: - try: - syslog.syslog(_pmap[notify_type], body) + try: + syslog.syslog(SYSLOG_PUBLISH_MAP[notify_type], body) - except KeyError: - # An invalid notification type was specified - self.logger.warning( - 'An invalid notification type ' - '({}) was specified.'.format(notify_type)) - return False + except KeyError: + # An invalid notification type was specified + self.logger.warning( + 'An invalid notification type ' + '({}) was specified.'.format(notify_type)) + return False - else: # SyslogMode.REMOTE - - host = self.host - port = self.port if self.port \ - else self.template_tokens['port']['default'] - if self.log_pid: - payload = '<%d>- %d - %s' % ( - _pmap[notify_type] + self.facility * 8, os.getpid(), body) - - else: - payload = '<%d>- %s' % ( - _pmap[notify_type] + self.facility * 8, body) - - # send UDP packet to upstream server - self.logger.debug( - 'Syslog Host: %s:%d/%s', - host, port, SYSLOG_FACILITY_RMAP[self.facility]) - self.logger.debug('Syslog Payload: %s' % str(payload)) - - # our sent bytes - sent = 0 - - try: - sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) - sock.settimeout(self.socket_connect_timeout) - sent = sock.sendto(payload.encode('utf-8'), (host, port)) - sock.close() - - except socket.gaierror as e: - self.logger.warning( - 'A connection error occurred sending Syslog ' - 'notification to %s:%d/%s', host, port, - SYSLOG_FACILITY_RMAP[self.facility] - ) - self.logger.debug('Socket Exception: %s' % str(e)) - return False - - except socket.timeout as e: - self.logger.warning( - 'A connection timeout occurred sending Syslog ' - 'notification to %s:%d/%s', host, port, - SYSLOG_FACILITY_RMAP[self.facility] - ) - self.logger.debug('Socket Exception: %s' % str(e)) - return False - - if sent < len(payload): - self.logger.warning( - 'Syslog sent %d byte(s) but intended to send %d byte(s)', - sent, len(payload)) - return False - - self.logger.info('Sent Syslog (%s) notification.', self.mode) + self.logger.info('Sent Syslog notification.') return True @@ -348,31 +251,16 @@ class NotifySyslog(NotifyBase): params = { 'logperror': 'yes' if self.log_perror else 'no', 'logpid': 'yes' if self.log_pid else 'no', - 'mode': self.mode, } # Extend our parameters params.update(self.url_parameters(privacy=privacy, *args, **kwargs)) - if self.mode == SyslogMode.LOCAL: - return '{schema}://{facility}/?{params}'.format( - facility=self.template_tokens['facility']['default'] - if self.facility not in SYSLOG_FACILITY_RMAP - else SYSLOG_FACILITY_RMAP[self.facility], - schema=self.secure_protocol, - params=NotifySyslog.urlencode(params), - ) - - # Remote mode: - return '{schema}://{hostname}{port}/{facility}/?{params}'.format( - schema=self.secure_protocol, - hostname=NotifySyslog.quote(self.host, safe=''), - port='' if self.port is None - or self.port == self.template_tokens['port']['default'] - else ':{}'.format(self.port), + return '{schema}://{facility}/?{params}'.format( facility=self.template_tokens['facility']['default'] if self.facility not in SYSLOG_FACILITY_RMAP else SYSLOG_FACILITY_RMAP[self.facility], + schema=self.protocol, params=NotifySyslog.urlencode(params), ) @@ -395,21 +283,12 @@ class NotifySyslog(NotifyBase): # Get our path values tokens.extend(NotifySyslog.split_path(results['fullpath'])) + # Initialization facility = None - if len(tokens) > 1 and is_hostname(tokens[0]): - # syslog://hostname/facility - results['mode'] = SyslogMode.REMOTE - # Store our facility as the first path entry - facility = tokens[-1] - - elif tokens: - # This is a bit ambigious... it could be either: - # syslog://facility -or- syslog://hostname - - # First lets test it as a facility; we'll correct this - # later on if nessisary - facility = tokens[-1] + if tokens: + # Store the last entry as the facility + facility = tokens[-1].lower() # However if specified on the URL, that will over-ride what was # identified @@ -425,20 +304,6 @@ class NotifySyslog(NotifyBase): facility = next((f for f in SYSLOG_FACILITY_MAP.keys() if f.startswith(facility)), facility) - # Attempt to solve our ambiguity - if len(tokens) == 1 and is_hostname(tokens[0]) and ( - results['port'] or facility not in SYSLOG_FACILITY_MAP): - - # facility is likely hostname; update our guessed mode - results['mode'] = SyslogMode.REMOTE - - # Reset our facility value - facility = None - - # Set mode if not otherwise set - if 'mode' in results['qsd'] and len(results['qsd']['mode']): - results['mode'] = NotifySyslog.unquote(results['qsd']['mode']) - # Save facility if set if facility: results['facility'] = facility diff --git a/lib/apprise/plugins/NotifyTechulusPush.py b/lib/apprise/plugins/NotifyTechulusPush.py index 0f3e79e5..3e2085c5 100644 --- a/lib/apprise/plugins/NotifyTechulusPush.py +++ b/lib/apprise/plugins/NotifyTechulusPush.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -# BSD 3-Clause License +# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2023, Chris Caron @@ -14,10 +14,6 @@ # 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 diff --git a/lib/apprise/plugins/NotifyTelegram.py b/lib/apprise/plugins/NotifyTelegram.py index e7d75f5a..121bf82a 100644 --- a/lib/apprise/plugins/NotifyTelegram.py +++ b/lib/apprise/plugins/NotifyTelegram.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -# BSD 3-Clause License +# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2023, Chris Caron @@ -14,10 +14,6 @@ # 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 @@ -84,6 +80,23 @@ IS_CHAT_ID_RE = re.compile( ) +class TelegramContentPlacement: + """ + The Telegram Content Placement + """ + # Before Attachments + BEFORE = "before" + # After Attachments + AFTER = "after" + + +# Identify Placement Categories +TELEGRAM_CONTENT_PLACEMENT = ( + TelegramContentPlacement.BEFORE, + TelegramContentPlacement.AFTER, +) + + class NotifyTelegram(NotifyBase): """ A wrapper for Telegram Notifications @@ -106,6 +119,9 @@ class NotifyTelegram(NotifyBase): # Telegram uses the http protocol with JSON requests notify_url = 'https://api.telegram.org/bot' + # Support attachments + attachment_support = True + # Allows the user to specify the NotifyImageSize object image_size = NotifyImageSize.XY_256 @@ -311,13 +327,24 @@ class NotifyTelegram(NotifyBase): 'type': 'bool', 'default': False, }, + 'topic': { + 'name': _('Topic Thread ID'), + 'type': 'int', + }, 'to': { 'alias_of': 'targets', }, + 'content': { + 'name': _('Content Placement'), + 'type': 'choice:string', + 'values': TELEGRAM_CONTENT_PLACEMENT, + 'default': TelegramContentPlacement.BEFORE, + }, }) def __init__(self, bot_token, targets, detect_owner=True, - include_image=False, silent=None, preview=None, **kwargs): + include_image=False, silent=None, preview=None, topic=None, + content=None, **kwargs): """ Initialize Telegram Object """ @@ -343,6 +370,29 @@ class NotifyTelegram(NotifyBase): self.preview = self.template_args['preview']['default'] \ if preview is None else bool(preview) + # Setup our content placement + self.content = self.template_args['content']['default'] \ + if not isinstance(content, str) else content.lower() + if self.content and self.content not in TELEGRAM_CONTENT_PLACEMENT: + msg = 'The content placement specified ({}) is invalid.'\ + .format(content) + self.logger.warning(msg) + raise TypeError(msg) + + if topic: + try: + self.topic = int(topic) + + except (TypeError, ValueError): + # Not a valid integer; ignore entry + err = 'The Telegram Topic ID specified ({}) is invalid.'\ + .format(topic) + self.logger.warning(err) + raise TypeError(err) + else: + # No Topic Thread + self.topic = None + # if detect_owner is set to True, we will attempt to determine who # the bot owner is based on the first person who messaged it. This # is not a fool proof way of doing things as over time Telegram removes @@ -419,11 +469,14 @@ class NotifyTelegram(NotifyBase): # content can arrive together. self.throttle() + payload = {'chat_id': chat_id} + if self.topic: + payload['message_thread_id'] = self.topic + try: with open(path, 'rb') as f: # Configure file payload (for upload) files = {key: (file_name, f)} - payload = {'chat_id': chat_id} self.logger.debug( 'Telegram attachment POST URL: %s (cert_verify=%r)' % ( @@ -632,6 +685,9 @@ class NotifyTelegram(NotifyBase): 'disable_web_page_preview': not self.preview, } + if self.topic: + payload['message_thread_id'] = self.topic + # Prepare Message Body if self.notify_format == NotifyFormat.MARKDOWN: payload['parse_mode'] = 'MARKDOWN' @@ -655,6 +711,10 @@ class NotifyTelegram(NotifyBase): # Prepare our payload based on HTML or TEXT payload['text'] = body + # Handle payloads without a body specified (but an attachment present) + attach_content = \ + TelegramContentPlacement.AFTER if not body else self.content + # Create a copy of the chat_ids list targets = list(self.targets) while len(targets): @@ -688,6 +748,20 @@ class NotifyTelegram(NotifyBase): 'Failed to send Telegram type image to {}.', payload['chat_id']) + if attach and self.attachment_support and \ + attach_content == TelegramContentPlacement.AFTER: + # Send our attachments now (if specified and if it exists) + if not self._send_attachments( + chat_id=payload['chat_id'], notify_type=notify_type, + attach=attach): + + has_error = True + continue + + if not body: + # Nothing more to do; move along to the next attachment + continue + # Always call throttle before any remote server i/o is made; # Telegram throttles to occur before sending the image so that # content can arrive together. @@ -750,19 +824,36 @@ class NotifyTelegram(NotifyBase): self.logger.info('Sent Telegram notification.') - if attach: - # Send our attachments now (if specified and if it exists) - for attachment in attach: - if not self.send_media( - payload['chat_id'], notify_type, - attach=attachment): + if attach and self.attachment_support \ + and attach_content == TelegramContentPlacement.BEFORE: + # Send our attachments now (if specified and if it exists) as + # it was identified to send the content before the attachments + # which is now done. + if not self._send_attachments( + chat_id=payload['chat_id'], + notify_type=notify_type, + attach=attach): - # We failed; don't continue - has_error = True - break + has_error = True + continue - self.logger.info( - 'Sent Telegram attachment: {}.'.format(attachment)) + return not has_error + + def _send_attachments(self, chat_id, notify_type, attach): + """ + Sends our attachments + """ + has_error = False + # Send our attachments now (if specified and if it exists) + for attachment in attach: + if not self.send_media(chat_id, notify_type, attach=attachment): + + # We failed; don't continue + has_error = True + break + + self.logger.info( + 'Sent Telegram attachment: {}.'.format(attachment)) return not has_error @@ -777,8 +868,12 @@ class NotifyTelegram(NotifyBase): 'detect': 'yes' if self.detect_owner else 'no', 'silent': 'yes' if self.silent else 'no', 'preview': 'yes' if self.preview else 'no', + 'content': self.content, } + if self.topic: + params['topic'] = self.topic + # Extend our parameters params.update(self.url_parameters(privacy=privacy, *args, **kwargs)) @@ -791,6 +886,12 @@ class NotifyTelegram(NotifyBase): [NotifyTelegram.quote('@{}'.format(x)) for x in self.targets]), params=NotifyTelegram.urlencode(params)) + def __len__(self): + """ + Returns the number of targets associated with this notification + """ + return len(self.targets) + @staticmethod def parse_url(url, **kwargs): """ @@ -851,6 +952,10 @@ class NotifyTelegram(NotifyBase): # Store our chat ids (as these are the remaining entries) results['targets'] = entries + # content to be displayed 'before' or 'after' attachments + if 'content' in results['qsd'] and len(results['qsd']['content']): + results['content'] = results['qsd']['content'] + # 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']): @@ -860,6 +965,10 @@ class NotifyTelegram(NotifyBase): # Store our bot token results['bot_token'] = bot_token + # Support Thread Topic + if 'topic' in results['qsd'] and len(results['qsd']['topic']): + results['topic'] = results['qsd']['topic'] + # Silent (Sends the message Silently); users will receive # notification with no sound. results['silent'] = \ diff --git a/lib/apprise/plugins/NotifyTwilio.py b/lib/apprise/plugins/NotifyTwilio.py index 78a1ba82..ab4c88e3 100644 --- a/lib/apprise/plugins/NotifyTwilio.py +++ b/lib/apprise/plugins/NotifyTwilio.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -# BSD 3-Clause License +# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2023, Chris Caron @@ -14,10 +14,6 @@ # 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 @@ -385,6 +381,13 @@ class NotifyTwilio(NotifyBase): [NotifyTwilio.quote(x, safe='') for x in self.targets]), params=NotifyTwilio.urlencode(params)) + def __len__(self): + """ + Returns the number of targets associated with this notification + """ + targets = len(self.targets) + return targets if targets > 0 else 1 + @staticmethod def parse_url(url): """ diff --git a/lib/apprise/plugins/NotifyTwist.py b/lib/apprise/plugins/NotifyTwist.py index a19f03a9..36a55313 100644 --- a/lib/apprise/plugins/NotifyTwist.py +++ b/lib/apprise/plugins/NotifyTwist.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -# BSD 3-Clause License +# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2023, Chris Caron @@ -14,10 +14,6 @@ # 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 @@ -106,10 +102,12 @@ class NotifyTwist(NotifyBase): 'name': _('Password'), 'type': 'string', 'private': True, + 'required': True, }, 'email': { 'name': _('Email'), 'type': 'string', + 'required': True, }, 'target_channel': { 'name': _('Target Channel'), @@ -256,6 +254,12 @@ class NotifyTwist(NotifyBase): params=NotifyTwist.urlencode(params), ) + def __len__(self): + """ + Returns the number of targets associated with this notification + """ + return len(self.channels) + len(self.channel_ids) + def login(self): """ A simple wrapper to authenticate with the Twist Server diff --git a/lib/apprise/plugins/NotifyTwitter.py b/lib/apprise/plugins/NotifyTwitter.py index 76c1a8e6..3647c8b3 100644 --- a/lib/apprise/plugins/NotifyTwitter.py +++ b/lib/apprise/plugins/NotifyTwitter.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -# BSD 3-Clause License +# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2023, Chris Caron @@ -14,10 +14,6 @@ # 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 @@ -36,6 +32,7 @@ import re import requests from copy import deepcopy from datetime import datetime +from datetime import timezone from requests_oauthlib import OAuth1 from json import dumps from json import loads @@ -82,11 +79,14 @@ class NotifyTwitter(NotifyBase): service_url = 'https://twitter.com/' # The default secure protocol is twitter. - secure_protocol = ('twitter', 'tweet') + secure_protocol = ('x', 'twitter', 'tweet') # A URL that takes you to the setup/help of the specific protocol setup_url = 'https://github.com/caronc/apprise/wiki/Notify_twitter' + # Support attachments + attachment_support = True + # Do not set body_maxlen as it is set in a property value below # since the length varies depending if we are doing a direct message # or a tweet @@ -124,13 +124,14 @@ class NotifyTwitter(NotifyBase): request_rate_per_sec = 0 # For Tracking Purposes - ratelimit_reset = datetime.utcnow() + ratelimit_reset = datetime.now(timezone.utc).replace(tzinfo=None) # Default to 1000; users can send up to 1000 DM's and 2400 tweets a day # This value only get's adjusted if the server sets it that way ratelimit_remaining = 1 templates = ( + '{schema}://{ckey}/{csecret}/{akey}/{asecret}', '{schema}://{ckey}/{csecret}/{akey}/{asecret}/{targets}', ) @@ -283,7 +284,7 @@ class NotifyTwitter(NotifyBase): # Build a list of our attachments attachments = [] - if attach: + if attach and self.attachment_support: # We need to upload our payload first so that we can source it # in remaining messages for attachment in attach: @@ -412,7 +413,7 @@ class NotifyTwitter(NotifyBase): _payload = deepcopy(payload) _payload['media_ids'] = media_ids - if no: + if no or not body: # strip text and replace it with the image representation _payload['status'] = \ '{:02d}/{:02d}'.format(no + 1, len(batches)) @@ -512,7 +513,7 @@ class NotifyTwitter(NotifyBase): 'additional_owners': ','.join([str(x) for x in targets.values()]) } - if no: + if no or not body: # strip text and replace it with the image representation _data['text'] = \ '{:02d}/{:02d}'.format(no + 1, len(attachments)) @@ -678,7 +679,7 @@ class NotifyTwitter(NotifyBase): # Twitter server. One would hope we're on NTP and our clocks are # the same allowing this to role smoothly: - now = datetime.utcnow() + now = datetime.now(timezone.utc).replace(tzinfo=None) if now < self.ratelimit_reset: # We need to throttle for the difference in seconds # We add 0.5 seconds to the end just to allow a grace @@ -736,8 +737,9 @@ class NotifyTwitter(NotifyBase): # Capture rate limiting if possible self.ratelimit_remaining = \ int(r.headers.get('x-rate-limit-remaining')) - self.ratelimit_reset = datetime.utcfromtimestamp( - int(r.headers.get('x-rate-limit-reset'))) + self.ratelimit_reset = datetime.fromtimestamp( + int(r.headers.get('x-rate-limit-reset')), timezone.utc + ).replace(tzinfo=None) except (TypeError, ValueError): # This is returned if we could not retrieve this information @@ -793,10 +795,6 @@ class NotifyTwitter(NotifyBase): # Extend our parameters params.update(self.url_parameters(privacy=privacy, *args, **kwargs)) - if len(self.targets) > 0: - params['to'] = ','.join( - [NotifyTwitter.quote(x, safe='') for x in self.targets]) - return '{schema}://{ckey}/{csecret}/{akey}/{asecret}' \ '/{targets}/?{params}'.format( schema=self.secure_protocol[0], @@ -811,6 +809,13 @@ class NotifyTwitter(NotifyBase): for target in self.targets]), params=NotifyTwitter.urlencode(params)) + def __len__(self): + """ + Returns the number of targets associated with this notification + """ + targets = len(self.targets) + return targets if targets > 0 else 1 + @staticmethod def parse_url(url): """ @@ -823,28 +828,22 @@ class NotifyTwitter(NotifyBase): # We're done early as we couldn't load the results return results - # The first token is stored in the hostname - consumer_key = NotifyTwitter.unquote(results['host']) - # Acquire remaining tokens tokens = NotifyTwitter.split_path(results['fullpath']) + # The consumer token is stored in the hostname + results['ckey'] = NotifyTwitter.unquote(results['host']) + + # # Now fetch the remaining tokens - try: - consumer_secret, access_token_key, access_token_secret = \ - tokens[0:3] + # - except (ValueError, AttributeError, IndexError): - # Force some bad values that will get caught - # in parsing later - consumer_secret = None - access_token_key = None - access_token_secret = None - - results['ckey'] = consumer_key - results['csecret'] = consumer_secret - results['akey'] = access_token_key - results['asecret'] = access_token_secret + # Consumer Secret + results['csecret'] = tokens.pop(0) if tokens else None + # Access Token Key + results['akey'] = tokens.pop(0) if tokens else None + # Access Token Secret + results['asecret'] = tokens.pop(0) if tokens else None # The defined twitter mode if 'mode' in results['qsd'] and len(results['qsd']['mode']): @@ -861,7 +860,7 @@ class NotifyTwitter(NotifyBase): results['targets'].append(results.get('user')) # Store any remaining items as potential targets - results['targets'].extend(tokens[3:]) + results['targets'].extend(tokens) # Get Cache Flag (reduces lookup hits) if 'cache' in results['qsd'] and len(results['qsd']['cache']): diff --git a/lib/apprise/plugins/NotifyVoipms.py b/lib/apprise/plugins/NotifyVoipms.py index 42379b6b..c39da4df 100644 --- a/lib/apprise/plugins/NotifyVoipms.py +++ b/lib/apprise/plugins/NotifyVoipms.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -# BSD 3-Clause License +# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2023, Chris Caron @@ -14,10 +14,6 @@ # 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 @@ -78,7 +74,6 @@ class NotifyVoipms(NotifyBase): # Define object templates templates = ( - '{schema}://{password}:{email}', '{schema}://{password}:{email}/{from_phone}/{targets}', ) @@ -111,6 +106,7 @@ class NotifyVoipms(NotifyBase): 'targets': { 'name': _('Targets'), 'type': 'list:string', + 'required': True, }, }) @@ -329,6 +325,13 @@ class NotifyVoipms(NotifyBase): ['1' + NotifyVoipms.quote(x, safe='') for x in self.targets]), params=NotifyVoipms.urlencode(params)) + def __len__(self): + """ + Returns the number of targets associated with this notification + """ + targets = len(self.targets) + return targets if targets > 0 else 1 + @staticmethod def parse_url(url): """ diff --git a/lib/apprise/plugins/NotifyVonage.py b/lib/apprise/plugins/NotifyVonage.py index 812c3643..48d82319 100644 --- a/lib/apprise/plugins/NotifyVonage.py +++ b/lib/apprise/plugins/NotifyVonage.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -# BSD 3-Clause License +# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2023, Chris Caron @@ -14,10 +14,6 @@ # 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 @@ -334,6 +330,13 @@ class NotifyVonage(NotifyBase): [NotifyVonage.quote(x, safe='') for x in self.targets]), params=NotifyVonage.urlencode(params)) + def __len__(self): + """ + Returns the number of targets associated with this notification + """ + targets = len(self.targets) + return targets if targets > 0 else 1 + @staticmethod def parse_url(url): """ diff --git a/lib/apprise/plugins/NotifyWebexTeams.py b/lib/apprise/plugins/NotifyWebexTeams.py index 6b953b71..67ed4e4b 100644 --- a/lib/apprise/plugins/NotifyWebexTeams.py +++ b/lib/apprise/plugins/NotifyWebexTeams.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -# BSD 3-Clause License +# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2023, Chris Caron @@ -14,10 +14,6 @@ # 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 diff --git a/lib/apprise/plugins/NotifyWhatsApp.py b/lib/apprise/plugins/NotifyWhatsApp.py new file mode 100644 index 00000000..efa90f89 --- /dev/null +++ b/lib/apprise/plugins/NotifyWhatsApp.py @@ -0,0 +1,559 @@ +# -*- coding: utf-8 -*- +# BSD 2-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. +# +# 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. +# +# API Source: +# https://developers.facebook.com/docs/whatsapp/cloud-api/reference/messages +# +# 1. Register a developer account with Meta: +# https://developers.facebook.com/docs/whatsapp/cloud-api/get-started +# 2. Enable 2 Factor Authentication (2FA) with your account (if not done +# already) +# 3. Create a App using WhatsApp Product. There are 2 to create an app from +# Do NOT chose the WhatsApp Webhook one (choose the other) +# +# When you click on the API Setup section of your new app you need to record +# both the access token and the From Phone Number ID. Note that this not the +# from phone number itself, but it's ID. It's displayed below and contains +# way more numbers then your typical phone number + +import re +import requests +from json import loads, dumps +from .NotifyBase import NotifyBase +from ..common import NotifyType +from ..utils import is_phone_no +from ..utils import parse_phone_no +from ..utils import validate_regex +from ..AppriseLocale import gettext_lazy as _ + + +class NotifyWhatsApp(NotifyBase): + """ + A wrapper for WhatsApp Notifications + """ + + # The default descriptive name associated with the Notification + service_name = 'WhatsApp' + + # The services URL + service_url = \ + 'https://developers.facebook.com/docs/whatsapp/cloud-api/get-started' + + # All notification requests are secure + secure_protocol = 'whatsapp' + + # Allow 300 requests per minute. + # 60/300 = 0.2 + request_rate_per_sec = 0.20 + + # Facebook Graph version + fb_graph_version = 'v17.0' + + # A URL that takes you to the setup/help of the specific protocol + setup_url = 'https://github.com/caronc/apprise/wiki/Notify_whatsapp' + + # WhatsApp Message Notification URL + notify_url = 'https://graph.facebook.com/{fb_ver}/{phone_id}/messages' + + # The maximum length of the body + body_maxlen = 1024 + + # A title can not be used for SMS Messages. Setting this to zero will + # cause any title (if defined) to get placed into the message body. + title_maxlen = 0 + + # Define object templates + templates = ( + '{schema}://{token}@{from_phone_id}/{targets}', + '{schema}://{template}:{token}@{from_phone_id}/{targets}', + ) + + # Define our template tokens + template_tokens = dict(NotifyBase.template_tokens, **{ + 'token': { + 'name': _('Access Token'), + 'type': 'string', + 'private': True, + 'required': True, + 'regex': (r'^[a-z0-9]+$', 'i'), + }, + 'template': { + 'name': _('Template Name'), + 'type': 'string', + 'required': False, + 'regex': (r'^[^\s]+$', 'i'), + }, + 'from_phone_id': { + 'name': _('From Phone ID'), + 'type': 'string', + 'private': True, + 'required': True, + 'regex': (r'^[0-9]+$', 'i'), + }, + 'target_phone': { + 'name': _('Target Phone No'), + 'type': 'string', + 'prefix': '+', + 'regex': (r'^[0-9\s)(+-]+$', 'i'), + 'map_to': 'targets', + }, + 'targets': { + 'name': _('Targets'), + 'type': 'list:string', + }, + 'language': { + 'name': _('Language'), + 'type': 'string', + 'default': 'en_US', + 'regex': (r'^[^0-9\s]+$', 'i'), + }, + }) + + # Define our template arguments + template_args = dict(NotifyBase.template_args, **{ + 'to': { + 'alias_of': 'targets', + }, + 'from': { + 'alias_of': 'from_phone_id', + }, + 'token': { + 'alias_of': 'token', + }, + 'template': { + 'alias_of': 'template', + }, + 'lang': { + 'alias_of': 'language', + }, + }) + + # Our supported mappings and component keys + component_key_re = re.compile( + r'(?P((?P[1-9][0-9]*)|(?Pbody|type)))', re.IGNORECASE) + + # Define any kwargs we're using + template_kwargs = { + 'template_mapping': { + 'name': _('Template Mapping'), + 'prefix': ':', + }, + } + + def __init__(self, token, from_phone_id, template=None, targets=None, + language=None, template_mapping=None, **kwargs): + """ + Initialize WhatsApp Object + """ + super().__init__(**kwargs) + + # The Access Token associated with the account + self.token = validate_regex( + token, *self.template_tokens['token']['regex']) + if not self.token: + msg = 'An invalid WhatsApp Access Token ' \ + '({}) was specified.'.format(token) + self.logger.warning(msg) + raise TypeError(msg) + + # The From Phone ID associated with the account + self.from_phone_id = validate_regex( + from_phone_id, *self.template_tokens['from_phone_id']['regex']) + if not self.from_phone_id: + msg = 'An invalid WhatsApp From Phone ID ' \ + '({}) was specified.'.format(from_phone_id) + self.logger.warning(msg) + raise TypeError(msg) + + # The template to associate with the message + if template: + self.template = validate_regex( + template, *self.template_tokens['template']['regex']) + if not self.template: + msg = 'An invalid WhatsApp Template Name ' \ + '({}) was specified.'.format(template) + self.logger.warning(msg) + raise TypeError(msg) + + # The Template language Code to use + if language: + self.language = validate_regex( + language, *self.template_tokens['language']['regex']) + if not self.language: + msg = 'An invalid WhatsApp Template Language Code ' \ + '({}) was specified.'.format(language) + self.logger.warning(msg) + raise TypeError(msg) + else: + self.language = self.template_tokens['language']['default'] + else: + # + # Message Mode + # + self.template = None + + # Parse our targets + self.targets = list() + + for target in parse_phone_no(targets): + # Validate targets and drop bad ones: + result = is_phone_no(target) + if not result: + self.logger.warning( + 'Dropped invalid phone # ' + '({}) specified.'.format(target), + ) + continue + + # store valid phone number + self.targets.append('+{}'.format(result['full'])) + + self.template_mapping = {} + if template_mapping: + # Store our extra payload entries + self.template_mapping.update(template_mapping) + + # Validate Mapping and prepare Components + self.components = dict() + self.component_keys = list() + for key, val in self.template_mapping.items(): + matched = self.component_key_re.match(key) + if not matched: + msg = 'An invalid Template Component ID ' \ + '({}) was specified.'.format(key) + self.logger.warning(msg) + raise TypeError(msg) + + if matched.group('id'): + # + # Manual Component Assigment (by id) + # + index = matched.group('id') + map_to = { + "type": "text", + "text": val, + } + + else: # matched.group('map') + map_to = matched.group('map').lower() + matched = self.component_key_re.match(val) + if not (matched and matched.group('id')): + msg = 'An invalid Template Component Mapping ' \ + '(:{}={}) was specified.'.format(key, val) + self.logger.warning(msg) + raise TypeError(msg) + index = matched.group('id') + + if index in self.components: + msg = 'The Template Component index ' \ + '({}) was already assigned.'.format(key) + self.logger.warning(msg) + raise TypeError(msg) + + self.components[index] = map_to + self.component_keys = self.components.keys() + # Adjust sorting and assume that the user put the order correctly; + # if not Facebook just won't be very happy and will reject the + # message + sorted(self.component_keys) + + return + + def send(self, body, title='', notify_type=NotifyType.INFO, **kwargs): + """ + Perform WhatsApp Notification + """ + + if not self.targets: + self.logger.warning( + 'There are no valid WhatsApp targets to notify.') + return False + + # error tracking (used for function return) + has_error = False + + # Prepare our URL + url = self.notify_url.format( + fb_ver=self.fb_graph_version, + phone_id=self.from_phone_id, + ) + + # Prepare our headers + headers = { + 'User-Agent': self.app_id, + 'Accept': 'application/json', + 'Content-Type': 'application/json', + 'Authorization': f'Bearer {self.token}', + } + + payload = { + 'messaging_product': 'whatsapp', + # The To gets populated in the loop below + 'to': None, + } + + if not self.template: + # + # Send Message + # + payload.update({ + 'recipient_type': "individual", + 'type': 'text', + 'text': {"body": body}, + }) + + else: + # + # Send Template + # + payload.update({ + 'type': 'template', + "template": { + "name": self.template, + "language": {"code": self.language}, + }, + }) + + if self.components: + payload['template']['components'] = [ + { + "type": "body", + "parameters": [], + } + ] + for key in self.component_keys: + if isinstance(self.components[key], dict): + # Manual Assignment + payload['template']['components'][0]["parameters"]\ + .append(self.components[key]) + continue + + # Mapping of body and/or notify type + payload['template']['components'][0]["parameters"].append({ + "type": "text", + "text": body if self.components[key] == 'body' + else notify_type, + }) + + # Create a copy of the targets list + targets = list(self.targets) + + while len(targets): + # Get our target to notify + target = targets.pop(0) + + # Prepare our user + payload['to'] = target + + # Some Debug Logging + self.logger.debug('WhatsApp POST URL: {} (cert_verify={})'.format( + url, self.verify_certificate)) + self.logger.debug('WhatsApp Payload: {}' .format(payload)) + + # Always call throttle before any remote server i/o is made + self.throttle() + try: + r = requests.post( + url, + data=dumps(payload), + headers=headers, + verify=self.verify_certificate, + timeout=self.request_timeout, + ) + + if r.status_code not in ( + requests.codes.created, requests.codes.ok): + # We had a problem + status_str = \ + NotifyBase.http_response_code_lookup(r.status_code) + + # set up our status code to use + status_code = r.status_code + + try: + # Update our status response if we can + json_response = loads(r.content) + status_code = \ + json_response['error'].get('code', status_code) + status_str = \ + json_response['error'].get('message', status_str) + + except (AttributeError, TypeError, ValueError, KeyError): + # KeyError = r.content is parseable but does not + # contain 'error' + # ValueError = r.content is Unparsable + # TypeError = r.content is None + # AttributeError = r is None + + # We could not parse JSON response. + # We will just use the status we already have. + pass + + self.logger.warning( + 'Failed to send WhatsApp notification to {}: ' + '{}{}error={}.'.format( + target, + status_str, + ', ' if status_str else '', + status_code)) + + self.logger.debug( + 'Response Details:\r\n{}'.format(r.content)) + + # Mark our failure + has_error = True + continue + + else: + self.logger.info( + 'Sent WhatsApp notification to {}.'.format(target)) + + except requests.RequestException as e: + self.logger.warning( + 'A Connection error occurred sending WhatsApp:%s ' % ( + target) + 'notification.' + ) + self.logger.debug('Socket Exception: %s' % str(e)) + + # Mark our failure + has_error = True + continue + + return not has_error + + def url(self, privacy=False, *args, **kwargs): + """ + Returns the URL built dynamically based on specified arguments. + """ + + # Define any URL parameters + params = {} + if self.template: + # Add language to our URL + params['lang'] = self.language + + # Extend our parameters + params.update(self.url_parameters(privacy=privacy, *args, **kwargs)) + + # Payload body extras prefixed with a ':' sign + # Append our payload extras into our parameters + params.update( + {':{}'.format(k): v for k, v in self.template_mapping.items()}) + + return '{schema}://{template}{token}@{from_id}/{targets}/?{params}'\ + .format( + schema=self.secure_protocol, + from_id=self.pprint( + self.from_phone_id, privacy, safe=''), + token=self.pprint(self.token, privacy, safe=''), + template='' if not self.template + else '{}:'.format( + NotifyWhatsApp.quote(self.template, safe='')), + targets='/'.join( + [NotifyWhatsApp.quote(x, safe='') for x in self.targets]), + params=NotifyWhatsApp.urlencode(params)) + + def __len__(self): + """ + Returns the number of targets associated with this notification + """ + targets = len(self.targets) + 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 our entries; split_path() looks after unquoting content for us + # by default + results['targets'] = NotifyWhatsApp.split_path(results['fullpath']) + + # The hostname is our From Phone ID + results['from_phone_id'] = NotifyWhatsApp.unquote(results['host']) + + # Determine if we have a Template, otherwise load our token + if results['password']: + # + # Template Mode + # + results['template'] = NotifyWhatsApp.unquote(results['user']) + results['token'] = NotifyWhatsApp.unquote(results['password']) + + else: + # + # Message Mode + # + results['token'] = NotifyWhatsApp.unquote(results['user']) + + # Access token + if 'token' in results['qsd'] and len(results['qsd']['token']): + # Extract the account sid from an argument + results['token'] = \ + NotifyWhatsApp.unquote(results['qsd']['token']) + + # Template + if 'template' in results['qsd'] and len(results['qsd']['template']): + results['template'] = results['qsd']['template'] + + # Template Language + if 'lang' in results['qsd'] and len(results['qsd']['lang']): + results['language'] = results['qsd']['lang'] + + # Support the 'from' and 'source' variable so that we can support + # targets this way too. + # The 'from' makes it easier to use yaml configuration + if 'from' in results['qsd'] and len(results['qsd']['from']): + results['from_phone_id'] = \ + NotifyWhatsApp.unquote(results['qsd']['from']) + if 'source' in results['qsd'] and \ + len(results['qsd']['source']): + results['from_phone_id'] = \ + NotifyWhatsApp.unquote(results['qsd']['source']) + + # Support the 'to' variable so that we can support targets this way too + # The 'to' makes it easier to use yaml configuration + if 'to' in results['qsd'] and len(results['qsd']['to']): + results['targets'] += \ + NotifyWhatsApp.parse_phone_no(results['qsd']['to']) + + # store any additional payload extra's defined + results['template_mapping'] = { + NotifyWhatsApp.unquote(x): NotifyWhatsApp.unquote(y) + for x, y in results['qsd:'].items() + } + + return results diff --git a/lib/apprise/plugins/NotifyWindows.py b/lib/apprise/plugins/NotifyWindows.py index b05e2ebb..226cf92b 100644 --- a/lib/apprise/plugins/NotifyWindows.py +++ b/lib/apprise/plugins/NotifyWindows.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -# BSD 3-Clause License +# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2023, Chris Caron @@ -14,10 +14,6 @@ # 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 @@ -142,7 +138,7 @@ class NotifyWindows(NotifyBase): win32gui.Shell_NotifyIcon(win32gui.NIM_DELETE, nid) win32api.PostQuitMessage(0) - return None + return 0 def send(self, body, title='', notify_type=NotifyType.INFO, **kwargs): """ diff --git a/lib/apprise/plugins/NotifyXBMC.py b/lib/apprise/plugins/NotifyXBMC.py index 963a74d8..a973989a 100644 --- a/lib/apprise/plugins/NotifyXBMC.py +++ b/lib/apprise/plugins/NotifyXBMC.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -# BSD 3-Clause License +# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2023, Chris Caron @@ -14,10 +14,6 @@ # 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 diff --git a/lib/apprise/plugins/NotifyXML.py b/lib/apprise/plugins/NotifyXML.py index bbb3046a..20eeb114 100644 --- a/lib/apprise/plugins/NotifyXML.py +++ b/lib/apprise/plugins/NotifyXML.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -# BSD 3-Clause License +# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2023, Chris Caron @@ -14,10 +14,6 @@ # 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 @@ -41,6 +37,16 @@ from ..common import NotifyType from ..AppriseLocale import gettext_lazy as _ +class XMLPayloadField: + """ + Identifies the fields available in the JSON Payload + """ + VERSION = 'Version' + TITLE = 'Subject' + MESSAGE = 'Message' + MESSAGETYPE = 'MessageType' + + # Defines the method to send the notification METHODS = ( 'POST', @@ -69,6 +75,9 @@ class NotifyXML(NotifyBase): # A URL that takes you to the setup/help of the specific protocol setup_url = 'https://github.com/caronc/apprise/wiki/Notify_Custom_XML' + # Support attachments + attachment_support = True + # Allows the user to specify the NotifyImageSize object image_size = NotifyImageSize.XY_128 @@ -78,7 +87,8 @@ class NotifyXML(NotifyBase): # XSD Information xsd_ver = '1.1' - xsd_url = 'https://raw.githubusercontent.com/caronc/apprise/master' \ + xsd_default_url = \ + 'https://raw.githubusercontent.com/caronc/apprise/master' \ '/apprise/assets/NotifyXML-{version}.xsd' # Define object templates @@ -161,7 +171,7 @@ class NotifyXML(NotifyBase): xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"> - + {{CORE}} {{ATTACHMENTS}} @@ -180,6 +190,18 @@ class NotifyXML(NotifyBase): self.logger.warning(msg) raise TypeError(msg) + # A payload map allows users to over-ride the default mapping if + # they're detected with the :overide=value. Normally this would + # create a new key and assign it the value specified. However + # if the key you specify is actually an internally mapped one, + # then a re-mapping takes place using the value + self.payload_map = { + XMLPayloadField.VERSION: XMLPayloadField.VERSION, + XMLPayloadField.TITLE: XMLPayloadField.TITLE, + XMLPayloadField.MESSAGE: XMLPayloadField.MESSAGE, + XMLPayloadField.MESSAGETYPE: XMLPayloadField.MESSAGETYPE, + } + self.params = {} if params: # Store our extra headers @@ -190,6 +212,7 @@ class NotifyXML(NotifyBase): # Store our extra headers self.headers.update(headers) + self.payload_overrides = {} self.payload_extras = {} if payload: # Store our extra payload entries (but tidy them up since they will @@ -201,7 +224,21 @@ class NotifyXML(NotifyBase): 'Ignoring invalid XML Stanza element name({})' .format(k)) continue - self.payload_extras[key] = v + + # Any values set in the payload to alter a system related one + # alters the system key. Hence :message=msg maps the 'message' + # variable that otherwise already contains the payload to be + # 'msg' instead (containing the payload) + if key in self.payload_map: + self.payload_map[key] = v + self.payload_overrides[key] = v + + else: + self.payload_extras[key] = v + + # Set our xsd url + self.xsd_url = None if self.payload_overrides or self.payload_extras \ + else self.xsd_default_url.format(version=self.xsd_ver) return @@ -227,6 +264,8 @@ class NotifyXML(NotifyBase): # Append our payload extra's into our parameters params.update( {':{}'.format(k): v for k, v in self.payload_extras.items()}) + params.update( + {':{}'.format(k): v for k, v in self.payload_overrides.items()}) # Determine Authentication auth = '' @@ -273,14 +312,21 @@ class NotifyXML(NotifyBase): # Our XML Attachmement subsitution xml_attachments = '' - # Our Payload Base - payload_base = { - 'Version': self.xsd_ver, - 'Subject': NotifyXML.escape_html(title, whitespace=False), - 'MessageType': NotifyXML.escape_html( - notify_type, whitespace=False), - 'Message': NotifyXML.escape_html(body, whitespace=False), - } + payload_base = {} + + for key, value in ( + (XMLPayloadField.VERSION, self.xsd_ver), + (XMLPayloadField.TITLE, NotifyXML.escape_html( + title, whitespace=False)), + (XMLPayloadField.MESSAGE, NotifyXML.escape_html( + body, whitespace=False)), + (XMLPayloadField.MESSAGETYPE, NotifyXML.escape_html( + notify_type, whitespace=False))): + + if not self.payload_map[key]: + # Do not store element in payload response + continue + payload_base[self.payload_map[key]] = value # Apply our payload extras payload_base.update( @@ -292,7 +338,7 @@ class NotifyXML(NotifyBase): ['<{}>{}'.format(k, v, k) for k, v in payload_base.items()]) attachments = [] - if attach: + if attach and self.attachment_support: for attachment in attach: # Perform some simple error checking if not attachment: @@ -328,7 +374,8 @@ class NotifyXML(NotifyBase): ''.join(attachments) + '' re_map = { - '{{XSD_URL}}': self.xsd_url.format(version=self.xsd_ver), + '{{XSD_URL}}': + f' xmlns:xsi="{self.xsd_url}"' if self.xsd_url else '', '{{ATTACHMENTS}}': xml_attachments, '{{CORE}}': xml_base, } diff --git a/lib/apprise/plugins/NotifyZulip.py b/lib/apprise/plugins/NotifyZulip.py index 19f3e29e..f0d0cd8d 100644 --- a/lib/apprise/plugins/NotifyZulip.py +++ b/lib/apprise/plugins/NotifyZulip.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -# BSD 3-Clause License +# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2023, Chris Caron @@ -14,10 +14,6 @@ # 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 @@ -131,6 +127,7 @@ class NotifyZulip(NotifyBase): 'name': _('Bot Name'), 'type': 'string', 'regex': (r'^[A-Z0-9_-]{1,32}$', 'i'), + 'required': True, }, 'organization': { 'name': _('Organization'), @@ -359,6 +356,12 @@ class NotifyZulip(NotifyBase): params=NotifyZulip.urlencode(params), ) + def __len__(self): + """ + Returns the number of targets associated with this notification + """ + return len(self.targets) + @staticmethod def parse_url(url): """ diff --git a/lib/apprise/plugins/__init__.py b/lib/apprise/plugins/__init__.py index 5560568b..27afef05 100644 --- a/lib/apprise/plugins/__init__.py +++ b/lib/apprise/plugins/__init__.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -# BSD 3-Clause License +# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2023, Chris Caron @@ -14,10 +14,6 @@ # 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 @@ -165,6 +161,9 @@ def _sanitize_token(tokens, default_delimiter): """ + # Used for tracking groups + group_map = {} + # Iterate over our tokens for key in tokens.keys(): @@ -181,14 +180,27 @@ def _sanitize_token(tokens, default_delimiter): # Default type to key tokens[key]['map_to'] = key + # Track our map_to objects + if tokens[key]['map_to'] not in group_map: + group_map[tokens[key]['map_to']] = set() + group_map[tokens[key]['map_to']].add(key) + if 'type' not in tokens[key]: # Default type to string tokens[key]['type'] = 'string' - elif tokens[key]['type'].startswith('list') \ - and 'delim' not in tokens[key]: - # Default list delimiter (if not otherwise specified) - tokens[key]['delim'] = default_delimiter + elif tokens[key]['type'].startswith('list'): + if 'delim' not in tokens[key]: + # Default list delimiter (if not otherwise specified) + tokens[key]['delim'] = default_delimiter + + if key in group_map[tokens[key]['map_to']]: # pragma: no branch + # Remove ourselves from the list + group_map[tokens[key]['map_to']].remove(key) + + # Pointing to the set directly so we can dynamically update + # ourselves + tokens[key]['group'] = group_map[tokens[key]['map_to']] elif tokens[key]['type'].startswith('choice') \ and 'default' not in tokens[key] \ @@ -266,6 +278,13 @@ def details(plugin): # # Identifies if the entry specified is required or not # 'required': True, # + # # Identifies all tokens detected to be associated with the + # # list:string + # # This is ony present in list:string objects and is only set + # # if this element acts as an alias for several other + # # kwargs/fields. + # 'group': [], + # # # Identify a default value # 'default': 'http', # diff --git a/lib/apprise/py3compat/__init__.py b/lib/apprise/py3compat/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/lib/apprise/py3compat/asyncio.py b/lib/apprise/py3compat/asyncio.py deleted file mode 100644 index a5313906..00000000 --- a/lib/apprise/py3compat/asyncio.py +++ /dev/null @@ -1,140 +0,0 @@ -# -*- coding: utf-8 -*- - -# Copyright (C) 2020 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. - -import sys -import asyncio -from functools import partial -from ..URLBase import URLBase -from ..logger import logger - - -# A global flag that tracks if we are Python v3.7 or higher -ASYNCIO_RUN_SUPPORT = \ - sys.version_info.major > 3 or \ - (sys.version_info.major == 3 and sys.version_info.minor >= 7) - - -async def notify(coroutines): - """ - An async wrapper to the AsyncNotifyBase.async_notify() calls allowing us - to call gather() and collect the responses - """ - - # Create log entry - logger.info( - 'Notifying {} service(s) asynchronously.'.format(len(coroutines))) - - results = await asyncio.gather(*coroutines, return_exceptions=True) - - # Returns True if all notifications succeeded, otherwise False is - # returned. - failed = any(not status or isinstance(status, Exception) - for status in results) - return not failed - - -def tosync(cor, debug=False): - """ - Await a coroutine from non-async code. - """ - - if ASYNCIO_RUN_SUPPORT: - try: - loop = asyncio.get_running_loop() - - except RuntimeError: - # There is no existing event loop, so we can start our own. - return asyncio.run(cor, debug=debug) - - else: - # Enable debug mode - loop.set_debug(debug) - - # Run the coroutine and wait for the result. - task = loop.create_task(cor) - return asyncio.ensure_future(task, loop=loop) - - else: - # The Deprecated Way (<= Python v3.6) - try: - # acquire access to our event loop - loop = asyncio.get_event_loop() - - except RuntimeError: - # This happens if we're inside a thread of another application - # where there is no running event_loop(). Pythong v3.7 and - # higher automatically take care of this case for us. But for - # the lower versions we need to do the following: - loop = asyncio.new_event_loop() - asyncio.set_event_loop(loop) - - # Enable debug mode - loop.set_debug(debug) - - return loop.run_until_complete(cor) - - -async def toasyncwrapvalue(v): - """ - Create a coroutine that, when run, returns the provided value. - """ - - return v - - -async def toasyncwrap(fn): - """ - Create a coroutine that, when run, executes the provided function. - """ - - return fn() - - -class AsyncNotifyBase(URLBase): - """ - asyncio wrapper for the NotifyBase object - """ - - async def async_notify(self, *args, **kwargs): - """ - Async Notification Wrapper - """ - - loop = asyncio.get_event_loop() - - try: - return await loop.run_in_executor( - None, partial(self.notify, *args, **kwargs)) - - except TypeError: - # These are our internally thrown notifications - pass - - except Exception: - # A catch-all so we don't have to abort early - # just because one of our plugins has a bug in it. - logger.exception("Notification Exception") - - return False diff --git a/lib/apprise/utils.py b/lib/apprise/utils.py index 60db30b1..8d644ce9 100644 --- a/lib/apprise/utils.py +++ b/lib/apprise/utils.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -# BSD 3-Clause License +# BSD 2-Clause License # # Apprise - Push Notification Library. # Copyright (c) 2023, Chris Caron @@ -14,10 +14,6 @@ # 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 @@ -36,6 +32,7 @@ import json import contextlib import os import hashlib +import locale from itertools import chain from os.path import expanduser from functools import reduce @@ -63,7 +60,13 @@ def import_module(path, name): except Exception as e: # module isn't loadable - del sys.modules[name] + try: + del sys.modules[name] + + except KeyError: + # nothing to clean up + pass + module = None logger.debug( @@ -136,14 +139,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 @@ -187,7 +190,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,]+' @@ -200,6 +203,9 @@ UUID4_RE = re.compile( r'[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}', re.IGNORECASE) +# Validate if we're a loadable Python file or not +VALID_PYTHON_FILE_RE = re.compile(r'.+\.py(o|c)?$', re.IGNORECASE) + # validate_regex() utilizes this mapping to track and re-use pre-complied # regular expressions REGEX_VALIDATE_LOOKUP = {} @@ -1110,7 +1116,7 @@ def urlencode(query, doseq=False, safe='', encoding=None, errors=None): errors=errors) -def parse_list(*args): +def parse_list(*args, cast=None): """ Take a string list and break it into a delimited list of arguments. This funciton also supports @@ -1133,6 +1139,9 @@ def parse_list(*args): result = [] for arg in args: + if not isinstance(arg, (str, set, list, bool, tuple)) and arg and cast: + arg = cast(arg) + if isinstance(arg, str): result += re.split(STRING_DELIMITERS, arg) @@ -1145,7 +1154,6 @@ def parse_list(*args): # Since Python v3 returns a filter (iterator) whereas Python v2 returned # a list, we need to change it into a list object to remain compatible with # both distribution types. - # TODO: Review after dropping support for Python 2. return sorted([x for x in filter(bool, list(set(result)))]) @@ -1479,7 +1487,7 @@ def environ(*remove, **update): # Create a backup of our environment for restoration purposes env_orig = os.environ.copy() - + loc_orig = locale.getlocale() try: os.environ.update(update) [os.environ.pop(k, None) for k in remove] @@ -1488,6 +1496,13 @@ def environ(*remove, **update): finally: # Restore our snapshot os.environ = env_orig.copy() + try: + # Restore locale + locale.setlocale(locale.LC_ALL, loc_orig) + + except locale.Error: + # Thrown in py3.6 + pass def apply_template(template, app_mode=TemplateType.RAW, **kwargs): @@ -1565,6 +1580,11 @@ def module_detection(paths, cache=True): # Since our plugin name can conflict (as a module) with another # we want to generate random strings to avoid steping on # another's namespace + if not (path and VALID_PYTHON_FILE_RE.match(path)): + # Ignore file/module type + logger.trace('Plugin Scan: Skipping %s', path) + return None + module_name = hashlib.sha1(path.encode('utf-8')).hexdigest() module_pyname = "{prefix}.{name}".format( prefix='apprise.custom.module', name=module_name)