diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5cbe919 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +local +metadata/local.meta diff --git a/README.md b/README.md new file mode 100644 index 0000000..2c8a3a4 --- /dev/null +++ b/README.md @@ -0,0 +1,23 @@ +## Apprise Alert Action +Adds an alert action to Splunk that allows sending a notification using any of the notification services supported by Apprise. + +For a full list of notification services, see https://github.com/caronc/apprise/wiki + +There are two ways of using this addon: + - Providing a URL in each alert action + - Using a configuration file and tags + +### Providing a URL in each alert action +This requires no configuration to use. Just put a valid URL in the alert action and the service will be sent the alert. + +### Using a configuration file and tags +See https://github.com/caronc/apprise/wiki/config for creating an Apprise configuration file. + +To provide the configuration file to the add-on, in the Splunk UI go to Settings>Alert Actions>Setup Apprise Alert Action* + +Alternatively, this can be done by updating and placing the below config in local/alert_actions.conf + + [apprise_alert] + param.config = <> + +Note: The default path the addon looks in for configuration files the apprise_alert/bin/ folder. Either provide a full path or relative path from this directory. \ No newline at end of file diff --git a/README/alert_actions.conf.spec b/README/alert_actions.conf.spec new file mode 100644 index 0000000..3f34065 --- /dev/null +++ b/README/alert_actions.conf.spec @@ -0,0 +1,3 @@ +[apprise_alert] +param.config = +* Location of your Apprise configuration file. Relative paths are from the within the apps/alert_apprise folder. \ No newline at end of file diff --git a/README/saved_searches.conf.spec b/README/saved_searches.conf.spec new file mode 100644 index 0000000..d494c8d --- /dev/null +++ b/README/saved_searches.conf.spec @@ -0,0 +1,19 @@ +#Options for Apprise Alert Action + +action.apprise_alert = [0|1] +* Enable Apprise Alert Action + +action.apprise_alert.param.url = +* The Notification service URL. Please see here for more info: https://github.com/caronc/apprise/wiki +* (optional, if tags is set) + +action.apprise_alert.param.tag = +* Tag to use to send notificaions. Requires a configuration file. +* (optional, if URL is set) + +action.apprise_alert.param.body = +* Body of the alert + +action.apprise_alert.param.title = +* Title of the alert +* (optional) \ No newline at end of file diff --git a/appserver/static/appIcon.png b/appserver/static/appIcon.png new file mode 100644 index 0000000..697cc5c Binary files /dev/null and b/appserver/static/appIcon.png differ diff --git a/bin/send_apprise_alert.py b/bin/send_apprise_alert.py new file mode 100644 index 0000000..c887460 --- /dev/null +++ b/bin/send_apprise_alert.py @@ -0,0 +1,78 @@ +import sys, requests, json, re, os + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "lib")) +import apprise + + +def eprint(*args, **kwargs): + print(*args, file=sys.stderr, **kwargs) + +def check_inputs(config): + required_fields = ['body'] + + if not 'url' in config and not 'tag' in config: + eprint("A URL or tag needs to be specified.") + return False + + if 'tag' in config and not 'config' in config: + eprint("Using a tag requires setting a configuration file defined in setup.") + return False + + if 'tag' in config and 'config' in config: + if not os.path.exists(config['config']): + eprint("Unable to locate config file {}".format(config['config'])) + return False + + for field in required_fields: + if not field in config: + eprint("No "+field+" specified.") + return False + + return True + + +if len(sys.argv) > 1 and sys.argv[1] == "--execute": + alert = json.load(sys.stdin) + if check_inputs(alert['configuration']): + #load config + config = alert['configuration'] + + + if 'config' in config and 'tag' in config: + ac = apprise.AppriseConfig() + ac.add(config['config']) + + ar = apprise.Apprise() + ar.add(ac) + + if "title" in config: + ar.notify( + body=config['body'], + title=config['title'], + tag=config['tag'] + ) + else: + ar.notify( + body=config['body'], + tag=config['tag'] + ) + + + if 'url' in config: + ar = apprise.Apprise() + ar.add(config['url']) + + if "title" in config: + ar.notify( + body=config['body'], + title=config['title'] + ) + else: + ar.notify( + body=config['body'] + ) + + else: + eprint("Invalid configuration detected. Stopped.") +else: + eprint("FATAL No execute flag given") diff --git a/default/alert_actions.conf b/default/alert_actions.conf new file mode 100644 index 0000000..dba21c8 --- /dev/null +++ b/default/alert_actions.conf @@ -0,0 +1,9 @@ +[apprise_alert] +is_custom = 1 +label = Send an Apprise Alert +description = Send an alert using Apprise +icon_path = appIcon.png +alert.execute.cmd = send_apprise_alert.py +alert.execute.cmd.arg.0 = --execute +payload_format = json +python.version = python3 diff --git a/default/app.conf b/default/app.conf new file mode 100644 index 0000000..e8875ae --- /dev/null +++ b/default/app.conf @@ -0,0 +1,16 @@ +[install] +state = enabled + +[package] +check_for_updates = 1 +id = alert_apprise + +[ui] +is_visible = false +is_manageable = false +label = Apprise Alert Action + +[launcher] +author = Michael Clayfield +version = 1.0.0 +description = Alert Action based on Apprise, for sending alerts to many different sources. diff --git a/default/data/ui/alerts/apprise_alert.html b/default/data/ui/alerts/apprise_alert.html new file mode 100644 index 0000000..d0c0c3a --- /dev/null +++ b/default/data/ui/alerts/apprise_alert.html @@ -0,0 +1,41 @@ +
+
+ +
+ +
+
+
+
+ + URL of the service to be called. Only required if not using a tag. See https://github.com/caronc/apprise/wiki for further info. + +
+
+
+ +
+ +
+
+
+
+ + Tag of the service(s) from your configuration file to call. Only required if not using a URL. + +
+
+
+ +
+ +
+
+
+ +
+ +
+
+ +
diff --git a/default/setup.xml b/default/setup.xml new file mode 100644 index 0000000..1b1293d --- /dev/null +++ b/default/setup.xml @@ -0,0 +1,9 @@ + + + + + text + + + + diff --git a/lib/apprise/Apprise.py b/lib/apprise/Apprise.py new file mode 100644 index 0000000..4c83c48 --- /dev/null +++ b/lib/apprise/Apprise.py @@ -0,0 +1,870 @@ +# -*- 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 asyncio +import concurrent.futures as cf +import os +from itertools import chain +from . import common +from .conversion import convert_between +from .utils import is_exclusive_match +from .utils import parse_list +from .utils import parse_urls +from .utils import cwe312_url +from .logger import logger +from .AppriseAsset import AppriseAsset +from .AppriseConfig import AppriseConfig +from .AppriseAttachment import AppriseAttachment +from .AppriseLocale import AppriseLocale +from .config.ConfigBase import ConfigBase +from .plugins.NotifyBase import NotifyBase + + +from . import plugins +from . import __version__ + + +class Apprise: + """ + Our Notification Manager + + """ + + def __init__(self, servers=None, asset=None, location=None, debug=False): + """ + Loads a set of server urls while applying the Asset() module to each + if specified. + + If no asset is provided, then the default asset is used. + + Optionally specify a global ContentLocation for a more strict means + of handling Attachments. + """ + + # Initialize a server list of URLs + self.servers = list() + + # Assigns an central asset object that will be later passed into each + # notification plugin. Assets contain information such as the local + # directory images can be found in. It can also identify remote + # URL paths that contain the images you want to present to the end + # user. If no asset is specified, then the default one is used. + self.asset = \ + asset if isinstance(asset, AppriseAsset) else AppriseAsset() + + if servers: + self.add(servers) + + # Initialize our locale object + self.locale = AppriseLocale() + + # Set our debug flag + self.debug = debug + + # Store our hosting location for optional strict rule handling + # of Attachments. Setting this to None removes any attachment + # restrictions. + self.location = location + + @staticmethod + def instantiate(url, asset=None, tag=None, suppress_exceptions=True): + """ + Returns the instance of a instantiated plugin based on the provided + Server URL. If the url fails to be parsed, then None is returned. + + The specified url can be either a string (the URL itself) or a + dictionary containing all of the components needed to istantiate + the notification service. If identifying a dictionary, at the bare + minimum, one must specify the schema. + + An example of a url dictionary object might look like: + { + schema: 'mailto', + host: 'google.com', + user: 'myuser', + password: 'mypassword', + } + + Alternatively the string is much easier to specify: + mailto://user:mypassword@google.com + + The dictionary works well for people who are calling details() to + extract the components they need to build the URL manually. + """ + + # Initialize our result set + results = None + + # Prepare our Asset Object + asset = asset if isinstance(asset, AppriseAsset) else AppriseAsset() + + if isinstance(url, str): + # Acquire our url tokens + results = plugins.url_to_dict( + url, secure_logging=asset.secure_logging) + + if results is None: + # Failed to parse the server URL; detailed logging handled + # inside url_to_dict - nothing to report here. + return None + + elif isinstance(url, dict): + # We already have our result set + results = url + + if results.get('schema') not in common.NOTIFY_SCHEMA_MAP: + # schema is a mandatory dictionary item as it is the only way + # we can index into our loaded plugins + logger.error('Dictionary does not include a "schema" entry.') + logger.trace( + 'Invalid dictionary unpacked as:{}{}'.format( + os.linesep, os.linesep.join( + ['{}="{}"'.format(k, v) + for k, v in results.items()]))) + return None + + logger.trace( + 'Dictionary unpacked as:{}{}'.format( + os.linesep, os.linesep.join( + ['{}="{}"'.format(k, v) for k, v in results.items()]))) + + # Otherwise we handle the invalid input specified + else: + logger.error( + 'An invalid URL type (%s) was specified for instantiation', + type(url)) + return None + + if not common.NOTIFY_SCHEMA_MAP[results['schema']].enabled: + # + # First Plugin Enable Check (Pre Initialization) + # + + # Plugin has been disabled at a global level + logger.error( + '%s:// is disabled on this system.', results['schema']) + return None + + # Build a list of tags to associate with the newly added notifications + results['tag'] = set(parse_list(tag)) + + # Set our Asset Object + results['asset'] = asset + + if suppress_exceptions: + try: + # Attempt to create an instance of our plugin using the parsed + # URL information + plugin = common.NOTIFY_SCHEMA_MAP[results['schema']](**results) + + # Create log entry of loaded URL + logger.debug( + 'Loaded {} URL: {}'.format( + common. + NOTIFY_SCHEMA_MAP[results['schema']].service_name, + plugin.url(privacy=asset.secure_logging))) + + except Exception: + # CWE-312 (Secure Logging) Handling + loggable_url = url if not asset.secure_logging \ + else cwe312_url(url) + + # the arguments are invalid or can not be used. + logger.error( + 'Could not load {} URL: {}'.format( + common. + NOTIFY_SCHEMA_MAP[results['schema']].service_name, + loggable_url)) + return None + + else: + # Attempt to create an instance of our plugin using the parsed + # URL information but don't wrap it in a try catch + plugin = common.NOTIFY_SCHEMA_MAP[results['schema']](**results) + + if not plugin.enabled: + # + # Second Plugin Enable Check (Post Initialization) + # + + # Service/Plugin is disabled (on a more local level). This is a + # case where the plugin was initially enabled but then after the + # __init__() was called under the hood something pre-determined + # that it could no longer be used. + + # The only downside to doing it this way is services are + # initialized prior to returning the details() if 3rd party tools + # are polling what is available. These services that become + # disabled thereafter are shown initially that they can be used. + logger.error( + '%s:// has become disabled on this system.', results['schema']) + return None + + return plugin + + def add(self, servers, asset=None, tag=None): + """ + Adds one or more server URLs into our list. + + You can override the global asset if you wish by including it with the + server(s) that you add. + + The tag allows you to associate 1 or more tag values to the server(s) + being added. tagging a service allows you to exclusively access them + when calling the notify() function. + """ + + # Initialize our return status + return_status = True + + if asset is None: + # prepare default asset + asset = self.asset + + if isinstance(servers, str): + # build our server list + servers = parse_urls(servers) + if len(servers) == 0: + return False + + elif isinstance(servers, dict): + # no problem, we support kwargs, convert it to a list + servers = [servers] + + elif isinstance(servers, (ConfigBase, NotifyBase, AppriseConfig)): + # Go ahead and just add our plugin into our list + self.servers.append(servers) + return True + + elif not isinstance(servers, (tuple, set, list)): + logger.error( + "An invalid notification (type={}) was specified.".format( + type(servers))) + return False + + for _server in servers: + + if isinstance(_server, (ConfigBase, NotifyBase, AppriseConfig)): + # Go ahead and just add our plugin into our list + self.servers.append(_server) + continue + + elif not isinstance(_server, (str, dict)): + logger.error( + "An invalid notification (type={}) was specified.".format( + type(_server))) + return_status = False + continue + + # Instantiate ourselves an object, this function throws or + # returns None if it fails + instance = Apprise.instantiate(_server, asset=asset, tag=tag) + if not isinstance(instance, NotifyBase): + # No logging is required as instantiate() handles failure + # and/or success reasons for us + return_status = False + continue + + # Add our initialized plugin to our server listings + self.servers.append(instance) + + # Return our status + return return_status + + def clear(self): + """ + Empties our server list + + """ + self.servers[:] = [] + + def find(self, tag=common.MATCH_ALL_TAG, match_always=True): + """ + Returns a list of all servers matching against the tag specified. + + """ + + # Build our tag setup + # - top level entries are treated as an 'or' + # - second level (or more) entries are treated as 'and' + # + # examples: + # tag="tagA, tagB" = tagA or tagB + # tag=['tagA', 'tagB'] = tagA or tagB + # tag=[('tagA', 'tagC'), 'tagB'] = (tagA and tagC) or tagB + # tag=[('tagB', 'tagC')] = tagB and tagC + + # A match_always flag allows us to pick up on our 'any' keyword + # and notify these services under all circumstances + match_always = common.MATCH_ALWAYS_TAG if match_always else None + + # Iterate over our loaded plugins + for entry in self.servers: + + if isinstance(entry, (ConfigBase, AppriseConfig)): + # load our servers + servers = entry.servers() + + else: + servers = [entry, ] + + for server in servers: + # Apply our tag matching based on our defined logic + if is_exclusive_match( + logic=tag, data=server.tags, + match_all=common.MATCH_ALL_TAG, + match_always=match_always): + yield server + return + + def notify(self, body, title='', notify_type=common.NotifyType.INFO, + body_format=None, tag=common.MATCH_ALL_TAG, match_always=True, + attach=None, interpret_escapes=None): + """ + Send a notification to all the plugins previously loaded. + + If the body_format specified is NotifyFormat.MARKDOWN, it will + be converted to HTML if the Notification type expects this. + + if the tag is specified (either a string or a set/list/tuple + of strings), then only the notifications flagged with that + tagged value are notified. By default, all added services + are notified (tag=MATCH_ALL_TAG) + + This function returns True if all notifications were successfully + sent, False if even just one of them fails, and None if no + notifications were sent at all as a result of tag filtering and/or + simply having empty configuration files that were read. + + Attach can contain a list of attachment URLs. attach can also be + represented by an AttachBase() (or list of) object(s). This + identifies the products you wish to notify + + Set interpret_escapes to True if you want to pre-escape a string + such as turning a \n into an actual new line, etc. + """ + + try: + # Process arguments and build synchronous and asynchronous calls + # (this step can throw internal errors). + 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, + interpret_escapes=interpret_escapes + ) + + except TypeError: + # No notifications sent, and there was an internal error. + return False + + if not sequential_calls and not parallel_calls: + # Nothing to send + return None + + 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): + """ + Send a notification to all the plugins previously loaded, for + asynchronous callers. + + The arguments are identical to those of Apprise.notify(). + + """ + try: + # Process arguments and build synchronous and asynchronous calls + # (this step can throw internal errors). + 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 sequential_calls and not parallel_calls: + # Nothing to send + return None + + 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 (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 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: + sequential.append((server, notify_kwargs)) + + return sequential, parallel + + def _create_notify_gen(self, body, title='', + notify_type=common.NotifyType.INFO, + body_format=None, tag=common.MATCH_ALL_TAG, + match_always=True, attach=None, + interpret_escapes=None): + """ + Internal generator function for _create_notify_calls(). + """ + + if len(self) == 0: + # Nothing to notify + msg = "There are no service(s) to notify" + logger.error(msg) + raise TypeError(msg) + + if not (title or body or attach): + msg = "No message content specified to deliver" + logger.error(msg) + raise TypeError(msg) + + try: + if title and isinstance(title, bytes): + title = title.decode(self.asset.encoding) + + if body and isinstance(body, bytes): + body = body.decode(self.asset.encoding) + + except UnicodeDecodeError: + msg = 'The content passed into Apprise was not of encoding ' \ + 'type: {}'.format(self.asset.encoding) + logger.error(msg) + raise TypeError(msg) + + # Tracks conversions + conversion_body_map = dict() + conversion_title_map = dict() + + # Prepare attachments if required + if attach is not None and not isinstance(attach, AppriseAttachment): + attach = AppriseAttachment( + attach, asset=self.asset, location=self.location) + + # Allow Asset default value + body_format = self.asset.body_format \ + if body_format is None else body_format + + # Allow Asset default value + interpret_escapes = self.asset.interpret_escapes \ + if interpret_escapes is None else interpret_escapes + + # Iterate over our loaded plugins + for server in self.find(tag, match_always=match_always): + # 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 + + # 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[key] = '' if not title else title + + # 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]) + + # Our body is always converted no matter what + conversion_body_map[key] = \ + convert_between( + body_format, server.notify_format, content=body) + + if interpret_escapes: + # + # Escape our content + # + + try: + # Added overhead required due to Python 3 Encoding Bug + # identified here: https://bugs.python.org/issue21331 + conversion_body_map[key] = \ + conversion_body_map[key]\ + .encode('ascii', 'backslashreplace')\ + .decode('unicode-escape') + + conversion_title_map[key] = \ + conversion_title_map[key]\ + .encode('ascii', 'backslashreplace')\ + .decode('unicode-escape') + + except AttributeError: + # Must be of string type + msg = 'Failed to escape message body' + logger.error(msg) + raise TypeError(msg) + + kwargs = dict( + body=conversion_body_map[key], + title=conversion_title_map[key], + notify_type=notify_type, + attach=attach, + body_format=body_format + ) + yield (server, kwargs) + + @staticmethod + def _notify_sequential(*servers_kwargs): + """ + Process a list of notify() calls sequentially and synchronously. + """ + + success = True + + for (server, kwargs) in servers_kwargs: + try: + # Send notification + result = server.notify(**kwargs) + 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 + def _notify_parallel_threadpool(*servers_kwargs): + """ + 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) 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) + and not isinstance(status, TypeError) for status in results): + # 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") + return False + + if any(isinstance(status, TypeError) for status in results): + # These are our internally thrown notifications. + return False + + return all(results) + + def details(self, lang=None, show_requirements=False, show_disabled=False): + """ + Returns the details associated with the Apprise object + + """ + + # general object returned + response = { + # Defines the current version of Apprise + 'version': __version__, + # Lists all of the currently supported Notifications + 'schemas': [], + # Includes the configured asset details + 'asset': self.asset.details(), + } + + for plugin in set(common.NOTIFY_SCHEMA_MAP.values()): + # Iterate over our hashed plugins and dynamically build details on + # their status: + + content = { + 'service_name': getattr(plugin, 'service_name', None), + 'service_url': getattr(plugin, 'service_url', None), + '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) + } + + # Standard protocol(s) should be None or a tuple + enabled = getattr(plugin, 'enabled', True) + if not show_disabled and not enabled: + # Do not show inactive plugins + continue + + elif show_disabled: + # Add current state to response + content['enabled'] = enabled + + # Standard protocol(s) should be None or a tuple + protocols = getattr(plugin, 'protocol', None) + if isinstance(protocols, str): + protocols = (protocols, ) + + # Secure protocol(s) should be None or a tuple + secure_protocols = getattr(plugin, 'secure_protocol', None) + if isinstance(secure_protocols, str): + secure_protocols = (secure_protocols, ) + + # Add our protocol details to our content + content.update({ + 'protocols': protocols, + 'secure_protocols': secure_protocols, + }) + + if not lang: + # Simply return our results + content['details'] = plugins.details(plugin) + if show_requirements: + content['requirements'] = plugins.requirements(plugin) + + else: + # Emulate the specified language when returning our results + with self.locale.lang_at(lang): + content['details'] = plugins.details(plugin) + if show_requirements: + content['requirements'] = plugins.requirements(plugin) + + # Build our response object + response['schemas'].append(content) + + return response + + def urls(self, privacy=False): + """ + Returns all of the loaded URLs defined in this apprise object. + """ + return [x.url(privacy=privacy) for x in self.servers] + + def pop(self, index): + """ + Removes an indexed Notification Service from the stack and returns it. + + The thing is we can never pop AppriseConfig() entries, only what was + loaded within them. So pop needs to carefully iterate over our list + and only track actual entries. + """ + + # Tracking variables + prev_offset = -1 + offset = prev_offset + + for idx, s in enumerate(self.servers): + if isinstance(s, (ConfigBase, AppriseConfig)): + servers = s.servers() + if len(servers) > 0: + # Acquire a new maximum offset to work with + offset = prev_offset + len(servers) + + if offset >= index: + # we can pop an element from our config stack + fn = s.pop if isinstance(s, ConfigBase) \ + else s.server_pop + + return fn(index if prev_offset == -1 + else (index - prev_offset - 1)) + + else: + offset = prev_offset + 1 + if offset == index: + return self.servers.pop(idx) + + # Update our old offset + prev_offset = offset + + # If we reach here, then we indexed out of range + raise IndexError('list index out of range') + + def __getitem__(self, index): + """ + Returns the indexed server entry of a loaded notification server + """ + # Tracking variables + prev_offset = -1 + offset = prev_offset + + for idx, s in enumerate(self.servers): + if isinstance(s, (ConfigBase, AppriseConfig)): + # Get our list of servers associate with our config object + servers = s.servers() + if len(servers) > 0: + # Acquire a new maximum offset to work with + offset = prev_offset + len(servers) + + if offset >= index: + return servers[index if prev_offset == -1 + else (index - prev_offset - 1)] + + else: + offset = prev_offset + 1 + if offset == index: + return self.servers[idx] + + # Update our old offset + prev_offset = offset + + # 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'. + True is returned if at least one service has been loaded. + """ + return len(self) > 0 + + def __iter__(self): + """ + Returns an iterator to each of our servers loaded. This includes those + found inside configuration. + """ + return chain(*[[s] if not isinstance(s, (ConfigBase, AppriseConfig)) + else iter(s.servers()) for s in self.servers]) + + def __len__(self): + """ + Returns the number of servers loaded; this includes those found within + loaded configuration. This funtion nnever actually counts the + Config entry themselves (if they exist), only what they contain. + """ + return sum([1 if not isinstance(s, (ConfigBase, AppriseConfig)) + else len(s.servers()) for s in self.servers]) diff --git a/lib/apprise/Apprise.pyi b/lib/apprise/Apprise.pyi new file mode 100644 index 0000000..5a34c9c --- /dev/null +++ b/lib/apprise/Apprise.pyi @@ -0,0 +1,62 @@ +from typing import Any, Dict, List, Iterable, Iterator, Optional + +from . import (AppriseAsset, AppriseAttachment, AppriseConfig, ConfigBase, + NotifyBase, NotifyFormat, NotifyType) +from .common import ContentLocation + +_Server = Union[str, ConfigBase, NotifyBase, AppriseConfig] +_Servers = Union[_Server, Dict[Any, _Server], Iterable[_Server]] +# Can't define this recursively as mypy doesn't support recursive types: +# https://github.com/python/mypy/issues/731 +_Tag = Union[str, Iterable[Union[str, Iterable[str]]]] + +class Apprise: + def __init__( + self, + servers: _Servers = ..., + asset: Optional[AppriseAsset] = ..., + location: Optional[ContentLocation] = ..., + debug: bool = ... + ) -> None: ... + @staticmethod + def instantiate( + url: Union[str, Dict[str, NotifyBase]], + asset: Optional[AppriseAsset] = ..., + tag: Optional[_Tag] = ..., + suppress_exceptions: bool = ... + ) -> NotifyBase: ... + def add( + self, + servers: _Servers = ..., + asset: Optional[AppriseAsset] = ..., + tag: Optional[_Tag] = ... + ) -> bool: ... + def clear(self) -> None: ... + def find(self, tag: str = ...) -> Iterator[Apprise]: ... + def notify( + self, + body: str, + title: str = ..., + notify_type: NotifyType = ..., + body_format: NotifyFormat = ..., + tag: _Tag = ..., + attach: Optional[AppriseAttachment] = ..., + interpret_escapes: Optional[bool] = ... + ) -> bool: ... + async def async_notify( + self, + body: str, + title: str = ..., + notify_type: NotifyType = ..., + body_format: NotifyFormat = ..., + tag: _Tag = ..., + attach: Optional[AppriseAttachment] = ..., + interpret_escapes: Optional[bool] = ... + ) -> bool: ... + def details(self, lang: Optional[str] = ...) -> Dict[str, Any]: ... + def urls(self, privacy: bool = ...) -> Iterable[str]: ... + def pop(self, index: int) -> ConfigBase: ... + def __getitem__(self, index: int) -> ConfigBase: ... + def __bool__(self) -> bool: ... + def __iter__(self) -> Iterator[ConfigBase]: ... + def __len__(self) -> int: ... \ No newline at end of file diff --git a/lib/apprise/AppriseAsset.py b/lib/apprise/AppriseAsset.py new file mode 100644 index 0000000..835c3b6 --- /dev/null +++ b/lib/apprise/AppriseAsset.py @@ -0,0 +1,346 @@ +# -*- 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 +from uuid import uuid4 +from os.path import join +from os.path import dirname +from os.path import isfile +from os.path import abspath +from .common import NotifyType +from .utils import module_detection + + +class AppriseAsset: + """ + Provides a supplimentary class that can be used to provide extra + information and details that can be used by Apprise such as providing + an alternate location to where images/icons can be found and the + URL masks. + + Any variable that starts with an underscore (_) can only be initialized + by this class manually and will/can not be parsed from a configuration + file. + + """ + # Application Identifier + app_id = 'Apprise' + + # Application Description + app_desc = 'Apprise Notifications' + + # Provider URL + app_url = 'https://github.com/caronc/apprise' + + # A Simple Mapping of Colors; For every NOTIFY_TYPE identified, + # there should be a mapping to it's color here: + html_notify_map = { + NotifyType.INFO: '#3AA3E3', + NotifyType.SUCCESS: '#3AA337', + NotifyType.FAILURE: '#A32037', + NotifyType.WARNING: '#CACF29', + } + + # Ascii Notification + ascii_notify_map = { + NotifyType.INFO: '[i]', + NotifyType.SUCCESS: '[+]', + NotifyType.FAILURE: '[!]', + NotifyType.WARNING: '[~]', + } + + # The default color to return if a mapping isn't found in our table above + default_html_color = '#888888' + + # The default image extension to use + default_extension = '.png' + + # The default theme + theme = 'default' + + # Image URL Mask + image_url_mask = \ + 'https://github.com/caronc/apprise/raw/master/apprise/assets/' \ + 'themes/{THEME}/apprise-{TYPE}-{XY}{EXTENSION}' + + # Application Logo + image_url_logo = \ + 'https://github.com/caronc/apprise/raw/master/apprise/assets/' \ + 'themes/{THEME}/apprise-logo.png' + + # Image Path Mask + image_path_mask = abspath(join( + dirname(__file__), + 'assets', + 'themes', + '{THEME}', + 'apprise-{TYPE}-{XY}{EXTENSION}', + )) + + # This value can also be set on calls to Apprise.notify(). This allows + # you to let Apprise upfront the type of data being passed in. This + # must be of type NotifyFormat. Possible values could be: + # - NotifyFormat.TEXT + # - NotifyFormat.MARKDOWN + # - NotifyFormat.HTML + # - None + # + # If no format is specified (hence None), then no special pre-formatting + # actions will take place during a notification. This has been and always + # will be the default. + body_format = None + + # Always attempt to send notifications asynchronous (as the same time + # if possible) + # This is a Python 3 supported option only. If set to False, then + # notifications are sent sequentially (one after another) + async_mode = True + + # Whether or not to interpret escapes found within the input text prior + # to passing it upstream. Such as converting \t to an actual tab and \n + # to a new line. + interpret_escapes = False + + # Defines the encoding of the content passed into Apprise + encoding = 'utf-8' + + # For more detail see CWE-312 @ + # https://cwe.mitre.org/data/definitions/312.html + # + # By enabling this, the logging output has additional overhead applied to + # it preventing secure password and secret information from being + # displayed in the logging. Since there is overhead involved in performing + # this cleanup; system owners who run in a very isolated environment may + # choose to disable this for a slight performance bump. It is recommended + # that you leave this option as is otherwise. + secure_logging = True + + # Optionally specify one or more path to attempt to scan for Python modules + # By default, no paths are scanned. + __plugin_paths = [] + + # All internal/system flags are prefixed with an underscore (_) + # These can only be initialized using Python libraries and are not picked + # up from (yaml) configuration files (if set) + + # An internal counter that is used by AppriseAPI + # (https://github.com/caronc/apprise-api). The idea is to allow one + # instance of AppriseAPI to call another, but to track how many times + # this occurs. It's intent is to prevent a loop where an AppriseAPI + # Server calls itself (or loops indefinitely) + _recursion = 0 + + # A unique identifer we can use to associate our calling source + _uid = str(uuid4()) + + def __init__(self, plugin_paths=None, **kwargs): + """ + Asset Initialization + + """ + # Assign default arguments if specified + for key, value in kwargs.items(): + if not hasattr(AppriseAsset, key): + raise AttributeError( + 'AppriseAsset init(): ' + 'An invalid key {} was specified.'.format(key)) + + setattr(self, key, value) + + if plugin_paths: + # Load any decorated modules if defined + module_detection(plugin_paths) + + def color(self, notify_type, color_type=None): + """ + Returns an HTML mapped color based on passed in notify type + + if color_type is: + None then a standard hex string is returned as + a string format ('#000000'). + + int then the integer representation is returned + tuple then the the red, green, blue is returned in a tuple + + """ + + # Attempt to get the type, otherwise return a default grey + # if we couldn't look up the entry + color = self.html_notify_map.get(notify_type, self.default_html_color) + if color_type is None: + # This is the default return type + return color + + elif color_type is int: + # Convert the color to integer + return AppriseAsset.hex_to_int(color) + + # The only other type is tuple + elif color_type is tuple: + return AppriseAsset.hex_to_rgb(color) + + # Unsupported type + raise ValueError( + 'AppriseAsset html_color(): An invalid color_type was specified.') + + def ascii(self, notify_type): + """ + Returns an ascii representation based on passed in notify type + + """ + + # look our response up + return self.ascii_notify_map.get(notify_type, self.default_html_color) + + def image_url(self, notify_type, image_size, logo=False, extension=None): + """ + Apply our mask to our image URL + + if logo is set to True, then the logo_url is used instead + + """ + + url_mask = self.image_url_logo if logo else self.image_url_mask + if not url_mask: + # No image to return + return None + + if extension is None: + extension = self.default_extension + + re_map = { + '{THEME}': self.theme if self.theme else '', + '{TYPE}': notify_type, + '{XY}': image_size, + '{EXTENSION}': extension, + } + + # Iterate over above list and store content accordingly + re_table = re.compile( + r'(' + '|'.join(re_map.keys()) + r')', + re.IGNORECASE, + ) + + return re_table.sub(lambda x: re_map[x.group()], url_mask) + + def image_path(self, notify_type, image_size, must_exist=True, + extension=None): + """ + Apply our mask to our image file path + + """ + + if not self.image_path_mask: + # No image to return + return None + + if extension is None: + extension = self.default_extension + + re_map = { + '{THEME}': self.theme if self.theme else '', + '{TYPE}': notify_type, + '{XY}': image_size, + '{EXTENSION}': extension, + } + + # Iterate over above list and store content accordingly + re_table = re.compile( + r'(' + '|'.join(re_map.keys()) + r')', + re.IGNORECASE, + ) + + # Acquire our path + path = re_table.sub(lambda x: re_map[x.group()], self.image_path_mask) + if must_exist and not isfile(path): + return None + + # Return what we parsed + return path + + def image_raw(self, notify_type, image_size, extension=None): + """ + Returns the raw image if it can (otherwise the function returns None) + + """ + + path = self.image_path( + notify_type=notify_type, + image_size=image_size, + extension=extension, + ) + if path: + try: + with open(path, 'rb') as fd: + return fd.read() + + except (OSError, IOError): + # We can't access the file + return None + + return None + + def details(self): + """ + Returns the details associated with the AppriseAsset object + + """ + return { + 'app_id': self.app_id, + 'app_desc': self.app_desc, + 'default_extension': self.default_extension, + 'theme': self.theme, + 'image_path_mask': self.image_path_mask, + 'image_url_mask': self.image_url_mask, + 'image_url_logo': self.image_url_logo, + } + + @staticmethod + def hex_to_rgb(value): + """ + Takes a hex string (such as #00ff00) and returns a tuple in the form + of (red, green, blue) + + eg: #00ff00 becomes : (0, 65535, 0) + + """ + value = value.lstrip('#') + lv = len(value) + return tuple(int(value[i:i + lv // 3], 16) + for i in range(0, lv, lv // 3)) + + @staticmethod + def hex_to_int(value): + """ + Takes a hex string (such as #00ff00) and returns its integer + equivalent + + eg: #00000f becomes : 15 + + """ + return int(value.lstrip('#'), 16) diff --git a/lib/apprise/AppriseAsset.pyi b/lib/apprise/AppriseAsset.pyi new file mode 100644 index 0000000..0830334 --- /dev/null +++ b/lib/apprise/AppriseAsset.pyi @@ -0,0 +1,34 @@ +from typing import Dict, Optional + +from . import NotifyFormat, NotifyType + +class AppriseAsset: + app_id: str + app_desc: str + app_url: str + html_notify_map: Dict[NotifyType, str] + default_html_color: str + default_extension: str + theme: Optional[str] + image_url_mask: str + image_url_logo: str + image_path_mask: Optional[str] + body_format: Optional[NotifyFormat] + async_mode: bool + interpret_escapes: bool + def __init__( + self, + app_id: str = ..., + app_desc: str = ..., + app_url: str = ..., + html_notify_map: Dict[NotifyType, str] = ..., + default_html_color: str = ..., + default_extension: str = ..., + theme: Optional[str] = ..., + image_url_mask: str = ..., + image_url_logo: str = ..., + image_path_mask: Optional[str] = ..., + body_format: Optional[NotifyFormat] = ..., + async_mode: bool = ..., + interpret_escapes: bool = ... + ) -> None: ... \ No newline at end of file diff --git a/lib/apprise/AppriseAttachment.py b/lib/apprise/AppriseAttachment.py new file mode 100644 index 0000000..e00645d --- /dev/null +++ b/lib/apprise/AppriseAttachment.py @@ -0,0 +1,324 @@ +# -*- 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. + +from . import attachment +from . import URLBase +from .AppriseAsset import AppriseAsset +from .logger import logger +from .common import ContentLocation +from .common import CONTENT_LOCATIONS +from .common import ATTACHMENT_SCHEMA_MAP +from .utils import GET_SCHEMA_RE + + +class AppriseAttachment: + """ + Our Apprise Attachment File Manager + + """ + + def __init__(self, paths=None, asset=None, cache=True, location=None, + **kwargs): + """ + Loads all of the paths/urls specified (if any). + + The path can either be a single string identifying one explicit + location, otherwise you can pass in a series of locations to scan + via a list. + + By default we cache our responses so that subsiquent calls does not + cause the content to be retrieved again. For local file references + this makes no difference at all. But for remote content, this does + mean more then one call can be made to retrieve the (same) data. This + method can be somewhat inefficient if disabled. Only disable caching + if you understand the consequences. + + You can alternatively set the cache value to an int identifying the + number of seconds the previously retrieved can exist for before it + should be considered expired. + + It's also worth nothing that the cache value is only set to elements + that are not already of subclass AttachBase() + + Optionally set your current ContentLocation in the location argument. + This is used to further handle attachments. The rules are as follows: + - INACCESSIBLE: You simply have disabled use of the object; no + attachments will be retrieved/handled. + - HOSTED: You are hosting an attachment service for others. + In these circumstances all attachments that are LOCAL + based (such as file://) will not be allowed. + - LOCAL: The least restrictive mode as local files can be + referenced in addition to hosted. + + In all both HOSTED and LOCAL modes, INACCESSIBLE attachment types will + continue to be inaccessible. However if you set this field (location) + to None (it's default value) the attachment location category will not + be tested in any way (all attachment types will be allowed). + + The location field is also a global option that can be set when + initializing the Apprise object. + + """ + + # Initialize our attachment listings + self.attachments = list() + + # Set our cache flag + self.cache = cache + + # Prepare our Asset Object + self.asset = \ + asset if isinstance(asset, AppriseAsset) else AppriseAsset() + + if location is not None and location not in CONTENT_LOCATIONS: + msg = "An invalid Attachment location ({}) was specified." \ + .format(location) + logger.warning(msg) + raise TypeError(msg) + + # Store our location + self.location = location + + # Now parse any paths specified + if paths is not None: + # Store our path(s) + if not self.add(paths): + # Parse Source domain based on from_addr + raise TypeError("One or more attachments could not be added.") + + def add(self, attachments, asset=None, cache=None): + """ + Adds one or more attachments into our list. + + By default we cache our responses so that subsiquent calls does not + cause the content to be retrieved again. For local file references + this makes no difference at all. But for remote content, this does + mean more then one call can be made to retrieve the (same) data. This + method can be somewhat inefficient if disabled. Only disable caching + if you understand the consequences. + + You can alternatively set the cache value to an int identifying the + number of seconds the previously retrieved can exist for before it + should be considered expired. + + It's also worth nothing that the cache value is only set to elements + that are not already of subclass AttachBase() + """ + # Initialize our return status + return_status = True + + # Initialize our default cache value + cache = cache if cache is not None else self.cache + + if asset is None: + # prepare default asset + asset = self.asset + + if isinstance(attachments, attachment.AttachBase): + # Go ahead and just add our attachments into our list + self.attachments.append(attachments) + return True + + elif isinstance(attachments, str): + # Save our path + attachments = (attachments, ) + + elif not isinstance(attachments, (tuple, set, list)): + logger.error( + 'An invalid attachment url (type={}) was ' + 'specified.'.format(type(attachments))) + return False + + # Iterate over our attachments + for _attachment in attachments: + if self.location == ContentLocation.INACCESSIBLE: + logger.warning( + "Attachments are disabled; ignoring {}" + .format(_attachment)) + return_status = False + continue + + if isinstance(_attachment, str): + logger.debug("Loading attachment: {}".format(_attachment)) + # Instantiate ourselves an object, this function throws or + # returns None if it fails + instance = AppriseAttachment.instantiate( + _attachment, asset=asset, cache=cache) + if not isinstance(instance, attachment.AttachBase): + return_status = False + continue + + elif isinstance(_attachment, AppriseAttachment): + # We were provided a list of Apprise Attachments + # append our content together + instance = _attachment.attachments + + elif not isinstance(_attachment, attachment.AttachBase): + logger.warning( + "An invalid attachment (type={}) was specified.".format( + type(_attachment))) + return_status = False + continue + + else: + # our entry is of type AttachBase, so just go ahead and point + # our instance to it for some post processing below + instance = _attachment + + # Apply some simple logic if our location flag is set + if self.location and (( + self.location == ContentLocation.HOSTED + and instance.location != ContentLocation.HOSTED) + or instance.location == ContentLocation.INACCESSIBLE): + logger.warning( + "Attachment was disallowed due to accessibility " + "restrictions ({}->{}): {}".format( + self.location, instance.location, + instance.url(privacy=True))) + return_status = False + continue + + # Add our initialized plugin to our server listings + if isinstance(instance, list): + self.attachments.extend(instance) + + else: + self.attachments.append(instance) + + # Return our status + return return_status + + @staticmethod + def instantiate(url, asset=None, cache=None, suppress_exceptions=True): + """ + Returns the instance of a instantiated attachment plugin based on + the provided Attachment URL. If the url fails to be parsed, then None + is returned. + + A specified cache value will over-ride anything set + + """ + # Attempt to acquire the schema at the very least to allow our + # attachment based urls. + schema = GET_SCHEMA_RE.match(url) + if schema is None: + # Plan B is to assume we're dealing with a file + schema = attachment.AttachFile.protocol + url = '{}://{}'.format(schema, URLBase.quote(url)) + + else: + # Ensure our schema is always in lower case + schema = schema.group('schema').lower() + + # Some basic validation + if schema not in ATTACHMENT_SCHEMA_MAP: + logger.warning('Unsupported schema {}.'.format(schema)) + return None + + # Parse our url details of the server object as dictionary containing + # all of the information parsed from our URL + results = ATTACHMENT_SCHEMA_MAP[schema].parse_url(url) + + if not results: + # Failed to parse the server URL + logger.warning('Unparseable URL {}.'.format(url)) + return None + + # Prepare our Asset Object + results['asset'] = \ + asset if isinstance(asset, AppriseAsset) else AppriseAsset() + + if cache is not None: + # Force an over-ride of the cache value to what we have specified + results['cache'] = cache + + if suppress_exceptions: + try: + # Attempt to create an instance of our plugin using the parsed + # URL information + attach_plugin = \ + ATTACHMENT_SCHEMA_MAP[results['schema']](**results) + + except Exception: + # the arguments are invalid or can not be used. + logger.warning('Could not load URL: %s' % url) + return None + + else: + # Attempt to create an instance of our plugin using the parsed + # URL information but don't wrap it in a try catch + attach_plugin = ATTACHMENT_SCHEMA_MAP[results['schema']](**results) + + return attach_plugin + + def clear(self): + """ + Empties our attachment list + + """ + self.attachments[:] = [] + + def size(self): + """ + Returns the total size of accumulated attachments + """ + return sum([len(a) for a in self.attachments if len(a) > 0]) + + def pop(self, index=-1): + """ + Removes an indexed Apprise Attachment from the stack and returns it. + + by default the last element is poped from the list + """ + # Remove our entry + return self.attachments.pop(index) + + def __getitem__(self, index): + """ + Returns the indexed entry of a loaded apprise attachments + """ + return self.attachments[index] + + def __bool__(self): + """ + Allows the Apprise object to be wrapped in an 'if statement'. + True is returned if at least one service has been loaded. + """ + return True if self.attachments else False + + def __iter__(self): + """ + Returns an iterator to our attachment list + """ + return iter(self.attachments) + + def __len__(self): + """ + Returns the number of attachment entries loaded + """ + return len(self.attachments) diff --git a/lib/apprise/AppriseAttachment.pyi b/lib/apprise/AppriseAttachment.pyi new file mode 100644 index 0000000..a28acb1 --- /dev/null +++ b/lib/apprise/AppriseAttachment.pyi @@ -0,0 +1,37 @@ +from typing import Any, Iterable, Optional, Union + +from . import AppriseAsset, ContentLocation +from .attachment import AttachBase + +_Attachment = Union[str, AttachBase] +_Attachments = Iterable[_Attachment] + +class AppriseAttachment: + def __init__( + self, + paths: Optional[_Attachments] = ..., + asset: Optional[AppriseAttachment] = ..., + cache: bool = ..., + location: Optional[ContentLocation] = ..., + **kwargs: Any + ) -> None: ... + def add( + self, + attachments: _Attachments, + asset: Optional[AppriseAttachment] = ..., + cache: Optional[bool] = ... + ) -> bool: ... + @staticmethod + def instantiate( + url: str, + asset: Optional[AppriseAsset] = ..., + cache: Optional[bool] = ..., + suppress_exceptions: bool = ... + ) -> NotifyBase: ... + def clear(self) -> None: ... + def size(self) -> int: ... + def pop(self, index: int = ...) -> AttachBase: ... + def __getitem__(self, index: int) -> AttachBase: ... + def __bool__(self) -> bool: ... + def __iter__(self) -> Iterator[AttachBase]: ... + def __len__(self) -> int: ... \ No newline at end of file diff --git a/lib/apprise/AppriseConfig.py b/lib/apprise/AppriseConfig.py new file mode 100644 index 0000000..07e7b48 --- /dev/null +++ b/lib/apprise/AppriseConfig.py @@ -0,0 +1,451 @@ +# -*- 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. + +from . import config +from . import ConfigBase +from . import CONFIG_FORMATS +from . import URLBase +from .AppriseAsset import AppriseAsset +from . import common +from .utils import GET_SCHEMA_RE +from .utils import parse_list +from .utils import is_exclusive_match +from .logger import logger + + +class AppriseConfig: + """ + Our Apprise Configuration File Manager + + - Supports a list of URLs defined one after another (text format) + - Supports a destinct YAML configuration format + + """ + + def __init__(self, paths=None, asset=None, cache=True, recursion=0, + insecure_includes=False, **kwargs): + """ + Loads all of the paths specified (if any). + + The path can either be a single string identifying one explicit + location, otherwise you can pass in a series of locations to scan + via a list. + + If no path is specified then a default list is used. + + By default we cache our responses so that subsiquent calls does not + cause the content to be retrieved again. Setting this to False does + mean more then one call can be made to retrieve the (same) data. This + method can be somewhat inefficient if disabled and you're set up to + make remote calls. Only disable caching if you understand the + consequences. + + You can alternatively set the cache value to an int identifying the + number of seconds the previously retrieved can exist for before it + should be considered expired. + + It's also worth nothing that the cache value is only set to elements + that are not already of subclass ConfigBase() + + recursion defines how deep we recursively handle entries that use the + `import` keyword. This keyword requires us to fetch more configuration + from another source and add it to our existing compilation. If the + file we remotely retrieve also has an `import` reference, we will only + advance through it if recursion is set to 2 deep. If set to zero + it is off. There is no limit to how high you set this value. It would + be recommended to keep it low if you do intend to use it. + + insecure includes by default are disabled. When set to True, all + Apprise Config files marked to be in STRICT mode are treated as being + in ALWAYS mode. + + Take a file:// based configuration for example, only a file:// based + configuration can import another file:// based one. because it is set + to STRICT mode. If an http:// based configuration file attempted to + import a file:// one it woul fail. However this import would be + possible if insecure_includes is set to True. + + There are cases where a self hosting apprise developer may wish to load + configuration from memory (in a string format) that contains import + entries (even file:// based ones). In these circumstances if you want + these includes to be honored, this value must be set to True. + """ + + # Initialize a server list of URLs + self.configs = list() + + # Prepare our Asset Object + self.asset = \ + asset if isinstance(asset, AppriseAsset) else AppriseAsset() + + # Set our cache flag + self.cache = cache + + # Initialize our recursion value + self.recursion = recursion + + # Initialize our insecure_includes flag + self.insecure_includes = insecure_includes + + if paths is not None: + # Store our path(s) + self.add(paths) + + return + + def add(self, configs, asset=None, tag=None, cache=True, recursion=None, + insecure_includes=None): + """ + Adds one or more config URLs into our list. + + You can override the global asset if you wish by including it with the + config(s) that you add. + + By default we cache our responses so that subsiquent calls does not + cause the content to be retrieved again. Setting this to False does + mean more then one call can be made to retrieve the (same) data. This + method can be somewhat inefficient if disabled and you're set up to + make remote calls. Only disable caching if you understand the + consequences. + + You can alternatively set the cache value to an int identifying the + number of seconds the previously retrieved can exist for before it + should be considered expired. + + It's also worth nothing that the cache value is only set to elements + that are not already of subclass ConfigBase() + + Optionally override the default recursion value. + + Optionally override the insecure_includes flag. + if insecure_includes is set to True then all plugins that are + set to a STRICT mode will be a treated as ALWAYS. + """ + + # Initialize our return status + return_status = True + + # Initialize our default cache value + cache = cache if cache is not None else self.cache + + # Initialize our default recursion value + recursion = recursion if recursion is not None else self.recursion + + # Initialize our default insecure_includes value + insecure_includes = \ + insecure_includes if insecure_includes is not None \ + else self.insecure_includes + + if asset is None: + # prepare default asset + asset = self.asset + + if isinstance(configs, ConfigBase): + # Go ahead and just add our configuration into our list + self.configs.append(configs) + return True + + elif isinstance(configs, str): + # Save our path + configs = (configs, ) + + elif not isinstance(configs, (tuple, set, list)): + logger.error( + 'An invalid configuration path (type={}) was ' + 'specified.'.format(type(configs))) + return False + + # Iterate over our configuration + for _config in configs: + + if isinstance(_config, ConfigBase): + # Go ahead and just add our configuration into our list + self.configs.append(_config) + continue + + elif not isinstance(_config, str): + logger.warning( + "An invalid configuration (type={}) was specified.".format( + type(_config))) + return_status = False + continue + + logger.debug("Loading configuration: {}".format(_config)) + + # Instantiate ourselves an object, this function throws or + # returns None if it fails + instance = AppriseConfig.instantiate( + _config, asset=asset, tag=tag, cache=cache, + recursion=recursion, insecure_includes=insecure_includes) + if not isinstance(instance, ConfigBase): + return_status = False + continue + + # Add our initialized plugin to our server listings + self.configs.append(instance) + + # Return our status + return return_status + + def add_config(self, content, asset=None, tag=None, format=None, + recursion=None, insecure_includes=None): + """ + Adds one configuration file in it's raw format. Content gets loaded as + a memory based object and only exists for the life of this + AppriseConfig object it was loaded into. + + If you know the format ('yaml' or 'text') you can specify + it for slightly less overhead during this call. Otherwise the + configuration is auto-detected. + + Optionally override the default recursion value. + + Optionally override the insecure_includes flag. + if insecure_includes is set to True then all plugins that are + set to a STRICT mode will be a treated as ALWAYS. + """ + + # Initialize our default recursion value + recursion = recursion if recursion is not None else self.recursion + + # Initialize our default insecure_includes value + insecure_includes = \ + insecure_includes if insecure_includes is not None \ + else self.insecure_includes + + if asset is None: + # prepare default asset + asset = self.asset + + if not isinstance(content, str): + logger.warning( + "An invalid configuration (type={}) was specified.".format( + type(content))) + return False + + logger.debug("Loading raw configuration: {}".format(content)) + + # Create ourselves a ConfigMemory Object to store our configuration + instance = config.ConfigMemory( + content=content, format=format, asset=asset, tag=tag, + recursion=recursion, insecure_includes=insecure_includes) + + if instance.config_format not in CONFIG_FORMATS: + logger.warning( + "The format of the configuration could not be deteced.") + return False + + # Add our initialized plugin to our server listings + self.configs.append(instance) + + # Return our status + return True + + def servers(self, tag=common.MATCH_ALL_TAG, match_always=True, *args, + **kwargs): + """ + Returns all of our servers dynamically build based on parsed + configuration. + + If a tag is specified, it applies to the configuration sources + themselves and not the notification services inside them. + + This is for filtering the configuration files polled for + results. + + If the anytag is set, then any notification that is found + set with that tag are included in the response. + + """ + + # A match_always flag allows us to pick up on our 'any' keyword + # and notify these services under all circumstances + match_always = common.MATCH_ALWAYS_TAG if match_always else None + + # Build our tag setup + # - top level entries are treated as an 'or' + # - second level (or more) entries are treated as 'and' + # + # examples: + # tag="tagA, tagB" = tagA or tagB + # tag=['tagA', 'tagB'] = tagA or tagB + # tag=[('tagA', 'tagC'), 'tagB'] = (tagA and tagC) or tagB + # tag=[('tagB', 'tagC')] = tagB and tagC + + response = list() + + for entry in self.configs: + + # Apply our tag matching based on our defined logic + if is_exclusive_match( + logic=tag, data=entry.tags, match_all=common.MATCH_ALL_TAG, + match_always=match_always): + # Build ourselves a list of services dynamically and return the + # as a list + response.extend(entry.servers()) + + return response + + @staticmethod + def instantiate(url, asset=None, tag=None, cache=None, + recursion=0, insecure_includes=False, + suppress_exceptions=True): + """ + Returns the instance of a instantiated configuration plugin based on + the provided Config URL. If the url fails to be parsed, then None + is returned. + + """ + # Attempt to acquire the schema at the very least to allow our + # configuration based urls. + schema = GET_SCHEMA_RE.match(url) + if schema is None: + # Plan B is to assume we're dealing with a file + schema = config.ConfigFile.protocol + url = '{}://{}'.format(schema, URLBase.quote(url)) + + else: + # Ensure our schema is always in lower case + schema = schema.group('schema').lower() + + # Some basic validation + if schema not in common.CONFIG_SCHEMA_MAP: + logger.warning('Unsupported schema {}.'.format(schema)) + return None + + # Parse our url details of the server object as dictionary containing + # all of the information parsed from our URL + results = common.CONFIG_SCHEMA_MAP[schema].parse_url(url) + + if not results: + # Failed to parse the server URL + logger.warning('Unparseable URL {}.'.format(url)) + return None + + # Build a list of tags to associate with the newly added notifications + results['tag'] = set(parse_list(tag)) + + # Prepare our Asset Object + results['asset'] = \ + asset if isinstance(asset, AppriseAsset) else AppriseAsset() + + if cache is not None: + # Force an over-ride of the cache value to what we have specified + results['cache'] = cache + + # Recursion can never be parsed from the URL + results['recursion'] = recursion + + # Insecure includes flag can never be parsed from the URL + results['insecure_includes'] = insecure_includes + + if suppress_exceptions: + try: + # Attempt to create an instance of our plugin using the parsed + # URL information + cfg_plugin = \ + common.CONFIG_SCHEMA_MAP[results['schema']](**results) + + except Exception: + # the arguments are invalid or can not be used. + logger.warning('Could not load URL: %s' % url) + return None + + else: + # Attempt to create an instance of our plugin using the parsed + # URL information but don't wrap it in a try catch + cfg_plugin = common.CONFIG_SCHEMA_MAP[results['schema']](**results) + + return cfg_plugin + + def clear(self): + """ + Empties our configuration list + + """ + self.configs[:] = [] + + def server_pop(self, index): + """ + Removes an indexed Apprise Notification from the servers + """ + + # Tracking variables + prev_offset = -1 + offset = prev_offset + + for entry in self.configs: + servers = entry.servers(cache=True) + if len(servers) > 0: + # Acquire a new maximum offset to work with + offset = prev_offset + len(servers) + + if offset >= index: + # we can pop an notification from our config stack + return entry.pop(index if prev_offset == -1 + else (index - prev_offset - 1)) + + # Update our old offset + prev_offset = offset + + # If we reach here, then we indexed out of range + raise IndexError('list index out of range') + + def pop(self, index=-1): + """ + Removes an indexed Apprise Configuration from the stack and returns it. + + By default, the last element is removed from the list + """ + # Remove our entry + return self.configs.pop(index) + + def __getitem__(self, index): + """ + Returns the indexed config entry of a loaded apprise configuration + """ + return self.configs[index] + + def __bool__(self): + """ + Allows the Apprise object to be wrapped in an 'if statement'. + True is returned if at least one service has been loaded. + """ + return True if self.configs else False + + def __iter__(self): + """ + Returns an iterator to our config list + """ + return iter(self.configs) + + def __len__(self): + """ + Returns the number of config entries loaded + """ + return len(self.configs) diff --git a/lib/apprise/AppriseConfig.pyi b/lib/apprise/AppriseConfig.pyi new file mode 100644 index 0000000..9ea819a --- /dev/null +++ b/lib/apprise/AppriseConfig.pyi @@ -0,0 +1,48 @@ +from typing import Any, Iterable, Iterator, List, Optional, Union + +from . import AppriseAsset, NotifyBase +from .config import ConfigBase + +_Configs = Union[ConfigBase, str, Iterable[str]] + +class AppriseConfig: + def __init__( + self, + paths: Optional[_Configs] = ..., + asset: Optional[AppriseAsset] = ..., + cache: bool = ..., + recursion: int = ..., + insecure_includes: bool = ..., + **kwargs: Any + ) -> None: ... + def add( + self, + configs: _Configs, + asset: Optional[AppriseAsset] = ..., + cache: bool = ..., + recursion: Optional[bool] = ..., + insecure_includes: Optional[bool] = ... + ) -> bool: ... + def add_config( + self, + content: str, + asset: Optional[AppriseAsset] = ..., + tag: Optional[str] = ..., + format: Optional[str] = ..., + recursion: Optional[int] = ..., + insecure_includes: Optional[bool] = ... + ) -> bool: ... + def servers(self, tag: str = ..., *args: Any, **kwargs: Any) -> List[ConfigBase]: ... + def instantiate( + url: str, + asset: Optional[AppriseAsset] = ..., + tag: Optional[str] = ..., + cache: Optional[bool] = ... + ) -> NotifyBase: ... + def clear(self) -> None: ... + def server_pop(self, index: int) -> ConfigBase: ... + def pop(self, index: int = ...) -> ConfigBase: ... + def __getitem__(self, index: int) -> ConfigBase: ... + def __bool__(self) -> bool: ... + def __iter__(self) -> Iterator[ConfigBase]: ... + def __len__(self) -> int: ... \ No newline at end of file diff --git a/lib/apprise/AppriseLocale.py b/lib/apprise/AppriseLocale.py new file mode 100644 index 0000000..c80afae --- /dev/null +++ b/lib/apprise/AppriseLocale.py @@ -0,0 +1,286 @@ +# -*- 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 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 + + +# This gets toggled to True if we succeed +GETTEXT_LOADED = False + +try: + # Initialize gettext + import gettext + + # Toggle our flag + GETTEXT_LOADED = True + +except ImportError: + # 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: + """ + Doesn't translate anything until str() or unicode() references + are made. + + """ + def __init__(self, text, *args, **kwargs): + """ + Store our text + """ + self.text = text + + super().__init__(*args, **kwargs) + + def __str__(self): + return LOCALE.gettext(self.text) if GETTEXT_LOADED else self.text + + +# Lazy translation handling +def gettext_lazy(text): + """ + A dummy function that can be referenced + """ + return LazyTranslation(text=text) diff --git a/lib/apprise/URLBase.py b/lib/apprise/URLBase.py new file mode 100644 index 0000000..1cea66d --- /dev/null +++ b/lib/apprise/URLBase.py @@ -0,0 +1,796 @@ +# -*- 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 +from .logger import logger +from time import sleep +from datetime import datetime +from xml.sax.saxutils import escape as sax_escape + +from urllib.parse import unquote as _unquote +from urllib.parse import quote as _quote + +from .AppriseLocale import gettext_lazy as _ +from .AppriseAsset import AppriseAsset +from .utils import urlencode +from .utils import parse_url +from .utils import parse_bool +from .utils import parse_list +from .utils import parse_phone_no + +# Used to break a path list into parts +PATHSPLIT_LIST_DELIM = re.compile(r'[ \t\r\n,\\/]+') + + +class PrivacyMode: + # Defines different privacy modes strings can be printed as + # Astrisk sets 4 of them: e.g. **** + # This is used for passwords + Secret = '*' + + # Outer takes the first and last character displaying them with + # 3 dots between. Hence, 'i-am-a-token' would become 'i...n' + Outer = 'o' + + # Displays the last four characters + Tail = 't' + + +# Define the HTML Lookup Table +HTML_LOOKUP = { + 400: 'Bad Request - Unsupported Parameters.', + 401: 'Verification Failed.', + 404: 'Page not found.', + 405: 'Method not allowed.', + 500: 'Internal server error.', + 503: 'Servers are overloaded.', +} + + +class URLBase: + """ + This is the base class for all URL Manipulation + """ + + # The default descriptive name associated with the URL + service_name = None + + # The default simple (insecure) protocol + # all inheriting entries must provide their protocol lookup + # protocol:// (in this example they would specify 'protocol') + protocol = None + + # The default secure protocol + # all inheriting entries must provide their protocol lookup + # protocols:// (in this example they would specify 'protocols') + # This value can be the same as the defined protocol. + secure_protocol = None + + # Throttle + request_rate_per_sec = 0 + + # The connect timeout is the number of seconds Requests will wait for your + # client to establish a connection to a remote machine (corresponding to + # the connect()) call on the socket. + socket_connect_timeout = 4.0 + + # The read timeout is the number of seconds the client will wait for the + # server to send a response. + socket_read_timeout = 4.0 + + # Handle + # Maintain a set of tags to associate with this specific notification + tags = set() + + # Secure sites should be verified against a Certificate Authority + verify_certificate = True + + # Logging to our global logger + logger = logger + + # Define a default set of template arguments used for dynamically building + # details about our individual plugins for developers. + + # Define object templates + templates = () + + # Provides a mapping of tokens, certain entries are fixed and automatically + # configured if found (such as schema, host, user, pass, and port) + template_tokens = {} + + # Here is where we define all of the arguments we accept on the url + # such as: schema://whatever/?cto=5.0&rto=15 + # These act the same way as tokens except they are optional and/or + # have default values set if mandatory. This rule must be followed + template_args = { + 'verify': { + 'name': _('Verify SSL'), + # SSL Certificate Authority Verification + 'type': 'bool', + # Provide a default + 'default': verify_certificate, + # look up default using the following parent class value at + # runtime. + '_lookup_default': 'verify_certificate', + }, + 'rto': { + 'name': _('Socket Read Timeout'), + 'type': 'float', + # Provide a default + 'default': socket_read_timeout, + # look up default using the following parent class value at + # runtime. The variable name identified here (in this case + # socket_read_timeout) is checked and it's result is placed + # over-top of the 'default'. This is done because once a parent + # class inherits this one, the overflow_mode already set as a + # default 'could' be potentially over-ridden and changed to a + # different value. + '_lookup_default': 'socket_read_timeout', + }, + 'cto': { + 'name': _('Socket Connect Timeout'), + 'type': 'float', + # Provide a default + 'default': socket_connect_timeout, + # look up default using the following parent class value at + # runtime. The variable name identified here (in this case + # socket_connect_timeout) is checked and it's result is placed + # over-top of the 'default'. This is done because once a parent + # class inherits this one, the overflow_mode already set as a + # default 'could' be potentially over-ridden and changed to a + # different value. + '_lookup_default': 'socket_connect_timeout', + }, + } + + # kwargs are dynamically built because a prefix causes us to parse the + # content slightly differently. The prefix is required and can be either + # a (+ or -). Below would handle the +key=value: + # { + # 'headers': { + # 'name': _('HTTP Header'), + # 'prefix': '+', + # 'type': 'string', + # }, + # }, + # + # In a kwarg situation, the 'key' is always presumed to be treated as + # a string. When the 'type' is defined, it is being defined to respect + # the 'value'. + + template_kwargs = {} + + def __init__(self, asset=None, **kwargs): + """ + Initialize some general logging and common server arguments that will + keep things consistent when working with the children that + inherit this class. + + """ + # Prepare our Asset Object + self.asset = \ + asset if isinstance(asset, AppriseAsset) else AppriseAsset() + + # Certificate Verification (for SSL calls); default to being enabled + self.verify_certificate = parse_bool(kwargs.get('verify', True)) + + # Secure Mode + 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') + if self.port: + try: + self.port = int(self.port) + + except (TypeError, ValueError): + self.logger.warning( + 'Invalid port number specified {}' + .format(self.port)) + self.port = None + + self.user = kwargs.get('user') + if self.user: + # Always unquote user if it exists + self.user = URLBase.unquote(self.user) + + self.password = kwargs.get('password') + if self.password: + # 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: + self.socket_read_timeout = float(kwargs.get('rto')) + except (TypeError, ValueError): + self.logger.warning( + 'Invalid socket read timeout (rto) was specified {}' + .format(kwargs.get('rto'))) + + if 'cto' in kwargs: + try: + self.socket_connect_timeout = float(kwargs.get('cto')) + + except (TypeError, ValueError): + self.logger.warning( + 'Invalid socket connect timeout (cto) was specified {}' + .format(kwargs.get('cto'))) + + if 'tag' in kwargs: + # We want to associate some tags with our notification service. + # the code below gets the 'tag' argument if defined, otherwise + # it just falls back to whatever was already defined globally + self.tags = set(parse_list(kwargs.get('tag'), self.tags)) + + # Tracks the time any i/o was made to the remote server. This value + # is automatically set and controlled through the throttle() call. + self._last_io_datetime = None + + def throttle(self, last_io=None, wait=None): + """ + A common throttle control + + if a wait is specified, then it will force a sleep of the + specified time if it is larger then the calculated throttle + time. + """ + + if last_io is not None: + # Assume specified last_io + self._last_io_datetime = last_io + + # Get ourselves a reference time of 'now' + reference = datetime.now() + + if self._last_io_datetime is None: + # Set time to 'now' and no need to throttle + self._last_io_datetime = reference + return + + if self.request_rate_per_sec <= 0.0 and not wait: + # We're done if there is no throttle limit set + return + + # If we reach here, we need to do additional logic. + # If the difference between the reference time and 'now' is less than + # the defined request_rate_per_sec then we need to throttle for the + # remaining balance of this time. + + elapsed = (reference - self._last_io_datetime).total_seconds() + + if wait is not None: + self.logger.debug('Throttling forced for {}s...'.format(wait)) + sleep(wait) + + elif elapsed < self.request_rate_per_sec: + self.logger.debug('Throttling for {}s...'.format( + self.request_rate_per_sec - elapsed)) + sleep(self.request_rate_per_sec - elapsed) + + # Update our timestamp before we leave + self._last_io_datetime = datetime.now() + return + + def url(self, privacy=False, *args, **kwargs): + """ + Assembles the URL associated with the notification based on the + arguments provied. + + """ + + # 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): + """ + Returns true if the tag specified is associated with this notification. + + tag can also be a tuple, set, and/or list + + """ + if isinstance(tags, (tuple, set, list)): + return bool(set(tags) & self.tags) + + # return any match + return tags in self.tags + + def __str__(self): + """ + Returns the url path + """ + return self.url(privacy=True) + + @staticmethod + def escape_html(html, convert_new_lines=False, whitespace=True): + """ + Takes html text as input and escapes it so that it won't + conflict with any xml/html wrapping characters. + + Args: + html (str): The HTML code to escape + convert_new_lines (:obj:`bool`, optional): escape new lines (\n) + whitespace (:obj:`bool`, optional): escape whitespace + + Returns: + str: The escaped html + """ + if not isinstance(html, str) or not html: + return '' + + # Escape HTML + escaped = sax_escape(html, {"'": "'", "\"": """}) + + if whitespace: + # Tidy up whitespace too + escaped = escaped\ + .replace(u'\t', u' ')\ + .replace(u' ', u' ') + + if convert_new_lines: + return escaped.replace(u'\n', u'
') + + return escaped + + @staticmethod + def unquote(content, encoding='utf-8', errors='replace'): + """ + Replace %xx escapes by their single-character equivalent. The optional + encoding and errors parameters specify how to decode percent-encoded + sequences. + + Wrapper to Python's `unquote` while remaining compatible with both + Python 2 & 3 since the reference to this function changed between + versions. + + Note: errors set to 'replace' means that invalid sequences are + replaced by a placeholder character. + + Args: + content (str): The quoted URI string you wish to unquote + encoding (:obj:`str`, optional): encoding type + errors (:obj:`str`, errors): how to handle invalid character found + in encoded string (defined by encoding) + + Returns: + str: The unquoted URI string + """ + if not content: + return '' + + return _unquote(content, encoding=encoding, errors=errors) + + @staticmethod + def quote(content, safe='/', encoding=None, errors=None): + """ Replaces single character non-ascii characters and URI specific + ones by their %xx code. + + Wrapper to Python's `quote` while remaining compatible with both + Python 2 & 3 since the reference to this function changed between + versions. + + Args: + content (str): The URI string you wish to quote + safe (str): non-ascii characters and URI specific ones that you + do not wish to escape (if detected). Setting this + string to an empty one causes everything to be + escaped. + encoding (:obj:`str`, optional): encoding type + errors (:obj:`str`, errors): how to handle invalid character found + in encoded string (defined by encoding) + + Returns: + str: The quoted URI string + """ + if not content: + return '' + + return _quote(content, safe=safe, encoding=encoding, errors=errors) + + @staticmethod + def pprint(content, privacy=True, mode=PrivacyMode.Outer, + # privacy print; quoting is ignored when privacy is set to True + quote=True, safe='/', encoding=None, errors=None): + """ + Privacy Print is used to mainpulate the string before passing it into + part of the URL. It is used to mask/hide private details such as + tokens, passwords, apikeys, etc from on-lookers. If the privacy=False + is set, then the quote variable is the next flag checked. + + Quoting is never done if the privacy flag is set to true to avoid + skewing the expected output. + """ + + if not privacy: + if quote: + # Return quoted string if specified to do so + return URLBase.quote( + content, safe=safe, encoding=encoding, errors=errors) + + # Return content 'as-is' + return content + + if mode is PrivacyMode.Secret: + # Return 4 Asterisks + return '****' + + if not isinstance(content, str) or not content: + # Nothing more to do + return '' + + if mode is PrivacyMode.Tail: + # Return the trailing 4 characters + return '...{}'.format(content[-4:]) + + # Default mode is Outer Mode + return '{}...{}'.format(content[0:1], content[-1:]) + + @staticmethod + def urlencode(query, doseq=False, safe='', encoding=None, errors=None): + """Convert a mapping object or a sequence of two-element tuples + + Wrapper to Python's `urlencode` while remaining compatible with both + Python 2 & 3 since the reference to this function changed between + versions. + + The resulting string is a series of key=value pairs separated by '&' + characters, where both key and value are quoted using the quote() + function. + + Note: If the dictionary entry contains an entry that is set to None + it is not included in the final result set. If you want to + pass in an empty variable, set it to an empty string. + + Args: + query (str): The dictionary to encode + doseq (:obj:`bool`, optional): Handle sequences + safe (:obj:`str`): non-ascii characters and URI specific ones that + you do not wish to escape (if detected). Setting this string + to an empty one causes everything to be escaped. + encoding (:obj:`str`, optional): encoding type + errors (:obj:`str`, errors): how to handle invalid character found + in encoded string (defined by encoding) + + Returns: + str: The escaped parameters returned as a string + """ + return urlencode( + query, doseq=doseq, safe=safe, encoding=encoding, errors=errors) + + @staticmethod + def split_path(path, unquote=True): + """Splits a URL up into a list object. + + Parses a specified URL and breaks it into a list. + + Args: + path (str): The path to split up into a list. + unquote (:obj:`bool`, optional): call unquote on each element + added to the returned list. + + Returns: + list: A list containing all of the elements in the path + """ + + try: + paths = PATHSPLIT_LIST_DELIM.split(path.lstrip('/')) + if unquote: + paths = \ + [URLBase.unquote(x) for x in filter(bool, paths)] + + except AttributeError: + # path is not useable, we still want to gracefully return an + # empty list + paths = [] + + return paths + + @staticmethod + def parse_list(content, unquote=True): + """A wrapper to utils.parse_list() with unquoting support + + Parses a specified set of data and breaks it into a list. + + Args: + content (str): The path to split up into a list. If a list is + provided, then it's individual entries are processed. + + unquote (:obj:`bool`, optional): call unquote on each element + added to the returned list. + + Returns: + list: A unique list containing all of the elements in the path + """ + + content = parse_list(content) + if unquote: + content = \ + [URLBase.unquote(x) for x in filter(bool, content)] + + return content + + @staticmethod + def parse_phone_no(content, unquote=True): + """A wrapper to utils.parse_phone_no() with unquoting support + + Parses a specified set of data and breaks it into a list. + + Args: + content (str): The path to split up into a list. If a list is + provided, then it's individual entries are processed. + + unquote (:obj:`bool`, optional): call unquote on each element + added to the returned list. + + Returns: + list: A unique list containing all of the elements in the path + """ + + if unquote: + try: + content = URLBase.unquote(content) + except TypeError: + # Nothing further to do + return [] + + content = parse_phone_no(content) + + return content + + @property + def app_id(self): + return self.asset.app_id if self.asset.app_id else '' + + @property + def app_desc(self): + return self.asset.app_desc if self.asset.app_desc else '' + + @property + def app_url(self): + return self.asset.app_url if self.asset.app_url else '' + + @property + def request_timeout(self): + """This is primarily used to fullfill the `timeout` keyword argument + that is used by requests.get() and requests.put() calls. + """ + 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 + simplify URL construction in the acommpanied url() function. + + The following property returns a dictionary (of strings) containing + all of the parameters that can be set on a URL and managed through + this class. + """ + + return { + # The socket read timeout + 'rto': str(self.socket_read_timeout), + # The request/socket connect timeout + 'cto': str(self.socket_connect_timeout), + # Certificate verification + 'verify': 'yes' if self.verify_certificate else 'no', + } + + @staticmethod + 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. + + + Args: + url (str): The URL you want to fully parse. + verify_host (:obj:`bool`, optional): a flag kept with the parsed + URL which some child classes will later use to verify SSL + keys (if SSL transactions take place). Unless under very + specific circumstances, it is strongly recomended that + you leave this default value set to True. + + Returns: + A dictionary is returned containing the URL fully parsed if + successful, otherwise None is returned. + """ + + results = parse_url( + url, default_schema='unknown', verify_host=verify_host, + 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 assume our secure flag is set. + results['secure'] = (results['schema'][-1] == 's') + + # Support SSL Certificate 'verify' keyword. Default to being enabled + results['verify'] = True + + if 'verify' in results['qsd']: + results['verify'] = parse_bool( + results['qsd'].get('verify', True)) + + # Password overrides + if 'password' in results['qsd']: + results['password'] = results['qsd']['password'] + if 'pass' in results['qsd']: + results['password'] = results['qsd']['pass'] + + # User overrides + 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'] + + # Store our socket connect timeout if specified + if 'cto' in results['qsd']: + results['cto'] = results['qsd']['cto'] + + if 'port' in results['qsd']: + results['port'] = results['qsd']['port'] + + return results + + @staticmethod + def http_response_code_lookup(code, response_mask=None): + """Parses the interger response code returned by a remote call from + a web request into it's human readable string version. + + You can over-ride codes or add new ones by providing your own + response_mask that contains a dictionary of integer -> string mapped + variables + """ + if isinstance(response_mask, dict): + # Apply any/all header over-rides defined + HTML_LOOKUP.update(response_mask) + + # Look up our response + try: + response = HTML_LOOKUP[code] + + except KeyError: + response = '' + + 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 + object.secure_protocol + """ + + schemas = set([]) + + for key in ('protocol', 'secure_protocol'): + schema = getattr(self, key, None) + if isinstance(schema, str): + schemas.add(schema) + + elif isinstance(schema, (set, list, tuple)): + # Support iterables list types + for s in schema: + if isinstance(s, str): + schemas.add(s) + + return schemas diff --git a/lib/apprise/URLBase.pyi b/lib/apprise/URLBase.pyi new file mode 100644 index 0000000..9158857 --- /dev/null +++ b/lib/apprise/URLBase.pyi @@ -0,0 +1,16 @@ +from logging import logger +from typing import Any, Iterable, Set, Optional + +class URLBase: + service_name: Optional[str] + protocol: Optional[str] + secure_protocol: Optional[str] + request_rate_per_sec: int + socket_connect_timeout: float + socket_read_timeout: float + tags: Set[str] + verify_certificate: bool + logger: logger + def url(self, privacy: bool = ..., *args: Any, **kwargs: Any) -> str: ... + def __contains__(self, tags: Iterable[str]) -> bool: ... + def __str__(self) -> str: ... \ No newline at end of file diff --git a/lib/apprise/__init__.py b/lib/apprise/__init__.py new file mode 100644 index 0000000..f8bb5c7 --- /dev/null +++ b/lib/apprise/__init__.py @@ -0,0 +1,91 @@ +# -*- 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. + +__title__ = 'Apprise' +__version__ = '1.6.0' +__author__ = 'Chris Caron' +__license__ = 'BSD' +__copywrite__ = 'Copyright (C) 2023 Chris Caron ' +__email__ = 'lead2gold@gmail.com' +__status__ = 'Production' + +from .common import NotifyType +from .common import NOTIFY_TYPES +from .common import NotifyImageSize +from .common import NOTIFY_IMAGE_SIZES +from .common import NotifyFormat +from .common import NOTIFY_FORMATS +from .common import OverflowMode +from .common import OVERFLOW_MODES +from .common import ConfigFormat +from .common import CONFIG_FORMATS +from .common import ContentIncludeMode +from .common import CONTENT_INCLUDE_MODES +from .common import ContentLocation +from .common import CONTENT_LOCATIONS + +from .URLBase import URLBase +from .URLBase import PrivacyMode +from .plugins.NotifyBase import NotifyBase +from .config.ConfigBase import ConfigBase +from .attachment.AttachBase import AttachBase + +from .Apprise import Apprise +from .AppriseAsset import AppriseAsset +from .AppriseConfig import AppriseConfig +from .AppriseAttachment import AppriseAttachment + +from . import decorators + +# Inherit our logging with our additional entries added to it +from .logger import logging +from .logger import logger +from .logger import LogCapture + +# Set default logging handler to avoid "No handler found" warnings. +logging.getLogger(__name__).addHandler(logging.NullHandler()) + +__all__ = [ + # Core + 'Apprise', 'AppriseAsset', 'AppriseConfig', 'AppriseAttachment', 'URLBase', + 'NotifyBase', 'ConfigBase', 'AttachBase', + + # Reference + 'NotifyType', 'NotifyImageSize', 'NotifyFormat', 'OverflowMode', + 'NOTIFY_TYPES', 'NOTIFY_IMAGE_SIZES', 'NOTIFY_FORMATS', 'OVERFLOW_MODES', + 'ConfigFormat', 'CONFIG_FORMATS', + 'ContentIncludeMode', 'CONTENT_INCLUDE_MODES', + 'ContentLocation', 'CONTENT_LOCATIONS', + 'PrivacyMode', + + # Decorator + 'decorators', + + # Logging + 'logging', 'logger', 'LogCapture', +] diff --git a/lib/apprise/assets/NotifyXML-1.0.xsd b/lib/apprise/assets/NotifyXML-1.0.xsd new file mode 100644 index 0000000..0e3f8f1 --- /dev/null +++ b/lib/apprise/assets/NotifyXML-1.0.xsd @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/lib/apprise/assets/NotifyXML-1.1.xsd b/lib/apprise/assets/NotifyXML-1.1.xsd new file mode 100644 index 0000000..cc6dbae --- /dev/null +++ b/lib/apprise/assets/NotifyXML-1.1.xsd @@ -0,0 +1,40 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/lib/apprise/assets/themes/default/apprise-failure-128x128.ico b/lib/apprise/assets/themes/default/apprise-failure-128x128.ico new file mode 100644 index 0000000..cddc091 Binary files /dev/null and b/lib/apprise/assets/themes/default/apprise-failure-128x128.ico differ diff --git a/lib/apprise/assets/themes/default/apprise-failure-128x128.png b/lib/apprise/assets/themes/default/apprise-failure-128x128.png new file mode 100644 index 0000000..f60f333 Binary files /dev/null and b/lib/apprise/assets/themes/default/apprise-failure-128x128.png differ diff --git a/lib/apprise/assets/themes/default/apprise-failure-256x256.png b/lib/apprise/assets/themes/default/apprise-failure-256x256.png new file mode 100644 index 0000000..2d4ccbb Binary files /dev/null and b/lib/apprise/assets/themes/default/apprise-failure-256x256.png differ diff --git a/lib/apprise/assets/themes/default/apprise-failure-32x32.png b/lib/apprise/assets/themes/default/apprise-failure-32x32.png new file mode 100644 index 0000000..f25bdfb Binary files /dev/null and b/lib/apprise/assets/themes/default/apprise-failure-32x32.png differ diff --git a/lib/apprise/assets/themes/default/apprise-failure-72x72.png b/lib/apprise/assets/themes/default/apprise-failure-72x72.png new file mode 100644 index 0000000..5dfff75 Binary files /dev/null and b/lib/apprise/assets/themes/default/apprise-failure-72x72.png differ diff --git a/lib/apprise/assets/themes/default/apprise-info-128x128.ico b/lib/apprise/assets/themes/default/apprise-info-128x128.ico new file mode 100644 index 0000000..3dfb3da Binary files /dev/null and b/lib/apprise/assets/themes/default/apprise-info-128x128.ico differ diff --git a/lib/apprise/assets/themes/default/apprise-info-128x128.png b/lib/apprise/assets/themes/default/apprise-info-128x128.png new file mode 100644 index 0000000..cf3ec32 Binary files /dev/null and b/lib/apprise/assets/themes/default/apprise-info-128x128.png differ diff --git a/lib/apprise/assets/themes/default/apprise-info-256x256.png b/lib/apprise/assets/themes/default/apprise-info-256x256.png new file mode 100644 index 0000000..b27d3b1 Binary files /dev/null and b/lib/apprise/assets/themes/default/apprise-info-256x256.png differ diff --git a/lib/apprise/assets/themes/default/apprise-info-32x32.png b/lib/apprise/assets/themes/default/apprise-info-32x32.png new file mode 100644 index 0000000..c92ebcb Binary files /dev/null and b/lib/apprise/assets/themes/default/apprise-info-32x32.png differ diff --git a/lib/apprise/assets/themes/default/apprise-info-72x72.png b/lib/apprise/assets/themes/default/apprise-info-72x72.png new file mode 100644 index 0000000..cd9a357 Binary files /dev/null and b/lib/apprise/assets/themes/default/apprise-info-72x72.png differ diff --git a/lib/apprise/assets/themes/default/apprise-logo.png b/lib/apprise/assets/themes/default/apprise-logo.png new file mode 100644 index 0000000..aa6824b Binary files /dev/null and b/lib/apprise/assets/themes/default/apprise-logo.png differ diff --git a/lib/apprise/assets/themes/default/apprise-success-128x128.ico b/lib/apprise/assets/themes/default/apprise-success-128x128.ico new file mode 100644 index 0000000..5a944f9 Binary files /dev/null and b/lib/apprise/assets/themes/default/apprise-success-128x128.ico differ diff --git a/lib/apprise/assets/themes/default/apprise-success-128x128.png b/lib/apprise/assets/themes/default/apprise-success-128x128.png new file mode 100644 index 0000000..21dfd83 Binary files /dev/null and b/lib/apprise/assets/themes/default/apprise-success-128x128.png differ diff --git a/lib/apprise/assets/themes/default/apprise-success-256x256.png b/lib/apprise/assets/themes/default/apprise-success-256x256.png new file mode 100644 index 0000000..5e2146d Binary files /dev/null and b/lib/apprise/assets/themes/default/apprise-success-256x256.png differ diff --git a/lib/apprise/assets/themes/default/apprise-success-32x32.png b/lib/apprise/assets/themes/default/apprise-success-32x32.png new file mode 100644 index 0000000..5e14492 Binary files /dev/null and b/lib/apprise/assets/themes/default/apprise-success-32x32.png differ diff --git a/lib/apprise/assets/themes/default/apprise-success-72x72.png b/lib/apprise/assets/themes/default/apprise-success-72x72.png new file mode 100644 index 0000000..99407ed Binary files /dev/null and b/lib/apprise/assets/themes/default/apprise-success-72x72.png differ diff --git a/lib/apprise/assets/themes/default/apprise-warning-128x128.ico b/lib/apprise/assets/themes/default/apprise-warning-128x128.ico new file mode 100644 index 0000000..c636a51 Binary files /dev/null and b/lib/apprise/assets/themes/default/apprise-warning-128x128.ico differ diff --git a/lib/apprise/assets/themes/default/apprise-warning-128x128.png b/lib/apprise/assets/themes/default/apprise-warning-128x128.png new file mode 100644 index 0000000..8510938 Binary files /dev/null and b/lib/apprise/assets/themes/default/apprise-warning-128x128.png differ diff --git a/lib/apprise/assets/themes/default/apprise-warning-256x256.png b/lib/apprise/assets/themes/default/apprise-warning-256x256.png new file mode 100644 index 0000000..3cc6a2d Binary files /dev/null and b/lib/apprise/assets/themes/default/apprise-warning-256x256.png differ diff --git a/lib/apprise/assets/themes/default/apprise-warning-32x32.png b/lib/apprise/assets/themes/default/apprise-warning-32x32.png new file mode 100644 index 0000000..bb56495 Binary files /dev/null and b/lib/apprise/assets/themes/default/apprise-warning-32x32.png differ diff --git a/lib/apprise/assets/themes/default/apprise-warning-72x72.png b/lib/apprise/assets/themes/default/apprise-warning-72x72.png new file mode 100644 index 0000000..24d304e Binary files /dev/null and b/lib/apprise/assets/themes/default/apprise-warning-72x72.png differ diff --git a/lib/apprise/attachment/AttachBase.py b/lib/apprise/attachment/AttachBase.py new file mode 100644 index 0000000..c1cadbf --- /dev/null +++ b/lib/apprise/attachment/AttachBase.py @@ -0,0 +1,377 @@ +# -*- 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 time +import mimetypes +from ..URLBase import URLBase +from ..utils import parse_bool +from ..common import ContentLocation +from ..AppriseLocale import gettext_lazy as _ + + +class AttachBase(URLBase): + """ + This is the base class for all supported attachment types + """ + + # For attachment type detection; this amount of data is read into memory + # 128KB (131072B) + max_detect_buffer_size = 131072 + + # Unknown mimetype + unknown_mimetype = 'application/octet-stream' + + # Our filename when we can't otherwise determine one + unknown_filename = 'apprise-attachment' + + # Our filename extension when we can't otherwise determine one + unknown_filename_extension = '.obj' + + # The strict argument is a flag specifying whether the list of known MIME + # types is limited to only the official types registered with IANA. When + # strict is True, only the IANA types are supported; when strict is False + # (the default), some additional non-standard but commonly used MIME types + # are also recognized. + strict = False + + # The maximum file-size we will accept for an attachment size. If this is + # set to zero (0), then no check is performed + # 1 MB = 1048576 bytes + # 5 MB = 5242880 bytes + # 1 GB = 1048576000 bytes + max_file_size = 1048576000 + + # By default all attachments types are inaccessible. + # Developers of items identified in the attachment plugin directory + # are requried to set a location + location = ContentLocation.INACCESSIBLE + + # Here is where we define all of the arguments we accept on the url + # such as: schema://whatever/?overflow=upstream&format=text + # These act the same way as tokens except they are optional and/or + # have default values set if mandatory. This rule must be followed + template_args = { + 'cache': { + 'name': _('Cache Age'), + 'type': 'int', + # We default to (600) which means we cache for 10 minutes + 'default': 600, + }, + 'mime': { + 'name': _('Forced Mime Type'), + 'type': 'string', + }, + 'name': { + 'name': _('Forced File Name'), + 'type': 'string', + }, + 'verify': { + 'name': _('Verify SSL'), + # SSL Certificate Authority Verification + 'type': 'bool', + # Provide a default + 'default': True, + }, + } + + def __init__(self, name=None, mimetype=None, cache=None, **kwargs): + """ + Initialize some general logging and common server arguments that will + keep things consistent when working with the configurations that + inherit this class. + + Optionally provide a filename to over-ride name associated with the + actual file retrieved (from where-ever). + + The mime-type is automatically detected, but you can over-ride this by + explicitly stating what it should be. + + By default we cache our responses so that subsiquent calls does not + cause the content to be retrieved again. For local file references + this makes no difference at all. But for remote content, this does + mean more then one call can be made to retrieve the (same) data. This + method can be somewhat inefficient if disabled. Only disable caching + if you understand the consequences. + + You can alternatively set the cache value to an int identifying the + number of seconds the previously retrieved can exist for before it + should be considered expired. + """ + + super().__init__(**kwargs) + + if not mimetypes.inited: + # Ensure mimetypes has been initialized + mimetypes.init() + + # Attach Filename (does not have to be the same as path) + self._name = name + + # The mime type of the attached content. This is detected if not + # otherwise specified. + self._mimetype = mimetype + + # The detected_mimetype, this is only used as a fallback if the + # mimetype wasn't forced by the user + self.detected_mimetype = None + + # The detected filename by calling child class. A detected filename + # is always used if no force naming was specified. + self.detected_name = None + + # Absolute path to attachment + self.download_path = None + + # Set our cache flag; it can be True, False, None, or a (positive) + # integer... nothing else + if cache is not None: + try: + self.cache = cache if isinstance(cache, bool) else int(cache) + + except (TypeError, ValueError): + err = 'An invalid cache value ({}) was specified.'.format( + cache) + self.logger.warning(err) + raise TypeError(err) + + # Some simple error checking + if self.cache < 0: + err = 'A negative cache value ({}) was specified.'.format( + cache) + self.logger.warning(err) + raise TypeError(err) + + else: + self.cache = None + + # Validate mimetype if specified + if self._mimetype: + if next((t for t in mimetypes.types_map.values() + if self._mimetype == t), None) is None: + err = 'An invalid mime-type ({}) was specified.'.format( + mimetype) + self.logger.warning(err) + raise TypeError(err) + + return + + @property + def path(self): + """ + Returns the absolute path to the filename. If this is not known or + is know but has been considered expired (due to cache setting), then + content is re-retrieved prior to returning. + """ + + if not self.exists(): + # we could not obtain our path + return None + + return self.download_path + + @property + def name(self): + """ + Returns the filename + """ + if self._name: + # return our fixed content + return self._name + + if not self.exists(): + # we could not obtain our name + return None + + if not self.detected_name: + # If we get here, our download was successful but we don't have a + # filename based on our content. + extension = mimetypes.guess_extension(self.mimetype) + self.detected_name = '{}{}'.format( + self.unknown_filename, + extension if extension else self.unknown_filename_extension) + + return self.detected_name + + @property + def mimetype(self): + """ + Returns mime type (if one is present). + + Content is cached once determied to prevent overhead of future + calls. + """ + + if self._mimetype: + # return our pre-calculated cached content + return self._mimetype + + if not self.exists(): + # we could not obtain our attachment + return None + + if not self.detected_mimetype: + # guess_type() returns: (type, encoding) and sets type to None + # if it can't otherwise determine it. + try: + # Directly reference _name and detected_name to prevent + # recursion loop (as self.name calls this function) + self.detected_mimetype, _ = mimetypes.guess_type( + self._name if self._name + else self.detected_name, strict=self.strict) + + except TypeError: + # Thrown if None was specified in filename section + pass + + # Return our mime type + return self.detected_mimetype \ + if self.detected_mimetype else self.unknown_mimetype + + def exists(self): + """ + Simply returns true if the object has downloaded and stored the + attachment AND the attachment has not expired. + """ + + cache = self.template_args['cache']['default'] \ + if self.cache is None else self.cache + + if self.download_path and os.path.isfile(self.download_path) \ + and cache: + + # We have enough reason to look further into our cached content + # and verify it has not expired. + if cache is True: + # return our fixed content as is; we will always cache it + return True + + # Verify our cache time to determine whether we will get our + # content again. + try: + age_in_sec = time.time() - os.stat(self.download_path).st_mtime + if age_in_sec <= cache: + return True + + except (OSError, IOError): + # The file is not present + pass + + return self.download() + + def invalidate(self): + """ + Release any temporary data that may be open by child classes. + Externally fetched content should be automatically cleaned up when + this function is called. + + This function should also reset the following entries to None: + - detected_name : Should identify a human readable filename + - download_path: Must contain a absolute path to content + - detected_mimetype: Should identify mimetype of content + """ + self.detected_name = None + self.download_path = None + self.detected_mimetype = None + return + + def download(self): + """ + This function must be over-ridden by inheriting classes. + + Inherited classes MUST populate: + - detected_name: Should identify a human readable filename + - download_path: Must contain a absolute path to content + - detected_mimetype: Should identify mimetype of content + + If a download fails, you should ensure these values are set to None. + """ + raise NotImplementedError( + "download() is implimented by the child class.") + + @staticmethod + def parse_url(url, verify_host=True, mimetype_db=None): + """Parses the URL and returns it broken apart into a dictionary. + + This is very specific and customized for Apprise. + + Args: + url (str): The URL you want to fully parse. + verify_host (:obj:`bool`, optional): a flag kept with the parsed + URL which some child classes will later use to verify SSL + keys (if SSL transactions take place). Unless under very + specific circumstances, it is strongly recomended that + you leave this default value set to True. + + Returns: + A dictionary is returned containing the URL fully parsed if + successful, otherwise None is returned. + """ + + results = URLBase.parse_url(url, verify_host=verify_host) + + if not results: + # We're done; we failed to parse our url + return results + + # Allow overriding the default config mime type + if 'mime' in results['qsd']: + results['mimetype'] = results['qsd'].get('mime', '') \ + .strip().lower() + + # Allow overriding the default file name + if 'name' in results['qsd']: + results['name'] = results['qsd'].get('name', '') \ + .strip().lower() + + # Our cache value + if 'cache' in results['qsd']: + # First try to get it's integer value + try: + results['cache'] = int(results['qsd']['cache']) + + except (ValueError, TypeError): + # No problem, it just isn't an integer; now treat it as a bool + # instead: + results['cache'] = parse_bool(results['qsd']['cache']) + + return results + + def __len__(self): + """ + Returns the filesize of the attachment. + + """ + return os.path.getsize(self.path) if self.path else 0 + + def __bool__(self): + """ + Allows the Apprise object to be wrapped in an based 'if statement'. + True is returned if our content was downloaded correctly. + """ + return True if self.path else False diff --git a/lib/apprise/attachment/AttachBase.pyi b/lib/apprise/attachment/AttachBase.pyi new file mode 100644 index 0000000..66b7179 --- /dev/null +++ b/lib/apprise/attachment/AttachBase.pyi @@ -0,0 +1,36 @@ +from typing import Any, Dict, Optional + +from .. import ContentLocation + +class AttachBase: + max_detect_buffer_size: int + unknown_mimetype: str + unknown_filename: str + unknown_filename_extension: str + strict: bool + max_file_size: int + location: ContentLocation + template_args: Dict[str, Any] + def __init__( + self, + name: Optional[str] = ..., + mimetype: Optional[str] = ..., + cache: Optional[bool] = ..., + **kwargs: Any + ) -> None: ... + @property + def path(self) -> Optional[str]: ... + @property + def name(self) -> Optional[str]: ... + @property + def mimetype(self) -> Optional[str]: ... + def exists(self) -> bool: ... + def invalidate(self) -> None: ... + def download(self) -> bool: ... + @staticmethod + def parse_url( + url: str, + verify_host: bool = ... + ) -> Dict[str, Any]: ... + def __len__(self) -> int: ... + def __bool__(self) -> bool: ... diff --git a/lib/apprise/attachment/AttachFile.py b/lib/apprise/attachment/AttachFile.py new file mode 100644 index 0000000..d308555 --- /dev/null +++ b/lib/apprise/attachment/AttachFile.py @@ -0,0 +1,141 @@ +# -*- 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 os +from .AttachBase import AttachBase +from ..common import ContentLocation +from ..AppriseLocale import gettext_lazy as _ + + +class AttachFile(AttachBase): + """ + A wrapper for File based attachment sources + """ + + # The default descriptive name associated with the service + service_name = _('Local File') + + # The default protocol + protocol = 'file' + + # Content is local to the same location as the apprise instance + # being called (server-side) + location = ContentLocation.LOCAL + + def __init__(self, path, **kwargs): + """ + Initialize Local File Attachment Object + + """ + super().__init__(**kwargs) + + # Store path but mark it dirty since we have not performed any + # verification at this point. + self.dirty_path = os.path.expanduser(path) + return + + def url(self, privacy=False, *args, **kwargs): + """ + Returns the URL built dynamically based on specified arguments. + """ + + # Define any URL parameters + params = {} + + if self._mimetype: + # A mime-type was enforced + params['mime'] = self._mimetype + + if self._name: + # A name was enforced + params['name'] = self._name + + return 'file://{path}{params}'.format( + path=self.quote(self.dirty_path), + params='?{}'.format(self.urlencode(params)) if params else '', + ) + + def download(self, **kwargs): + """ + Perform retrieval of our data. + + For file base attachments, our data already exists, so we only need to + validate it. + """ + + if self.location == ContentLocation.INACCESSIBLE: + # our content is inaccessible + return False + + # Ensure any existing content set has been invalidated + self.invalidate() + + if not os.path.isfile(self.dirty_path): + return False + + if self.max_file_size > 0 and \ + os.path.getsize(self.dirty_path) > self.max_file_size: + + # The content to attach is to large + self.logger.error( + 'Content exceeds allowable maximum file length ' + '({}KB): {}'.format( + int(self.max_file_size / 1024), self.url(privacy=True))) + + # Return False (signifying a failure) + return False + + # We're good to go if we get here. Set our minimum requirements of + # a call do download() before returning a success + self.download_path = self.dirty_path + self.detected_name = os.path.basename(self.download_path) + + # We don't need to set our self.detected_mimetype as it can be + # pulled at the time it's needed based on the detected_name + return True + + @staticmethod + def parse_url(url): + """ + Parses the URL so that we can handle all different file paths + and return it as our path object + + """ + + results = AttachBase.parse_url(url, verify_host=False) + if not results: + # We're done early; it's not a good URL + return results + + match = re.match(r'file://(?P[^?]+)(\?.*)?', url, re.I) + if not match: + return None + + results['path'] = AttachFile.unquote(match.group('path')) + return results diff --git a/lib/apprise/attachment/AttachHTTP.py b/lib/apprise/attachment/AttachHTTP.py new file mode 100644 index 0000000..0c85947 --- /dev/null +++ b/lib/apprise/attachment/AttachHTTP.py @@ -0,0 +1,337 @@ +# -*- 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 os +import requests +from tempfile import NamedTemporaryFile +from .AttachBase import AttachBase +from ..common import ContentLocation +from ..URLBase import PrivacyMode +from ..AppriseLocale import gettext_lazy as _ + + +class AttachHTTP(AttachBase): + """ + A wrapper for HTTP based attachment sources + """ + + # The default descriptive name associated with the service + service_name = _('Web Based') + + # The default protocol + protocol = 'http' + + # The default secure protocol + secure_protocol = 'https' + + # The number of bytes in memory to read from the remote source at a time + chunk_size = 8192 + + # Web based requests are remote/external to our current location + location = ContentLocation.HOSTED + + def __init__(self, headers=None, **kwargs): + """ + Initialize HTTP 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.schema = 'https' if self.secure else 'http' + + self.fullpath = kwargs.get('fullpath') + if not isinstance(self.fullpath, str): + self.fullpath = '/' + + self.headers = {} + if headers: + # Store our extra headers + self.headers.update(headers) + + # Where our content is written to upon a call to download. + self._temp_file = None + + # Our Query String Dictionary; we use this to track arguments + # specified that aren't otherwise part of this class + self.qsd = {k: v for k, v in kwargs.get('qsd', {}).items() + if k not in self.template_args} + + return + + def download(self, **kwargs): + """ + Perform retrieval of the configuration based on the specified request + """ + + if self.location == ContentLocation.INACCESSIBLE: + # our content is inaccessible + return False + + # Ensure any existing content set has been invalidated + self.invalidate() + + # prepare header + headers = { + 'User-Agent': self.app_id, + } + + # Apply any/all header over-rides defined + headers.update(self.headers) + + auth = None + if self.user: + auth = (self.user, self.password) + + url = '%s://%s' % (self.schema, self.host) + if isinstance(self.port, int): + url += ':%d' % self.port + + url += self.fullpath + + self.logger.debug('HTTP POST URL: %s (cert_verify=%r)' % ( + url, self.verify_certificate, + )) + + # Where our request object will temporarily live. + r = None + + # Always call throttle before any remote server i/o is made + self.throttle() + + try: + # Make our request + with requests.get( + url, + headers=headers, + auth=auth, + params=self.qsd, + verify=self.verify_certificate, + timeout=self.request_timeout, + stream=True) as r: + + # Handle Errors + r.raise_for_status() + + # Get our file-size (if known) + try: + file_size = int(r.headers.get('Content-Length', '0')) + except (TypeError, ValueError): + # Handle edge case where Content-Length is a bad value + file_size = 0 + + # Perform a little Q/A on file limitations and restrictions + if self.max_file_size > 0 and file_size > self.max_file_size: + + # The content retrieved is to large + self.logger.error( + 'HTTP response exceeds allowable maximum file length ' + '({}KB): {}'.format( + int(self.max_file_size / 1024), + self.url(privacy=True))) + + # Return False (signifying a failure) + return False + + # Detect config format based on mime if the format isn't + # already enforced + self.detected_mimetype = r.headers.get('Content-Type') + + d = r.headers.get('Content-Disposition', '') + result = re.search( + "filename=['\"]?(?P[^'\"]+)['\"]?", d, re.I) + if result: + self.detected_name = result.group('name').strip() + + # Create a temporary file to work with + self._temp_file = NamedTemporaryFile() + + # Get our chunk size + chunk_size = self.chunk_size + + # Track all bytes written to disk + bytes_written = 0 + + # If we get here, we can now safely write our content to disk + for chunk in r.iter_content(chunk_size=chunk_size): + # filter out keep-alive chunks + if chunk: + self._temp_file.write(chunk) + bytes_written = self._temp_file.tell() + + # Prevent a case where Content-Length isn't provided + # we don't want to fetch beyond our limits + if self.max_file_size > 0: + if bytes_written > self.max_file_size: + # The content retrieved is to large + self.logger.error( + 'HTTP response exceeds allowable maximum ' + 'file length ({}KB): {}'.format( + int(self.max_file_size / 1024), + self.url(privacy=True))) + + # Invalidate any variables previously set + self.invalidate() + + # Return False (signifying a failure) + return False + + elif bytes_written + chunk_size \ + > self.max_file_size: + # Adjust out next read to accomodate up to our + # limit +1. This will prevent us from readig + # to much into our memory buffer + self.max_file_size - bytes_written + 1 + + # Ensure our content is flushed to disk for post-processing + self._temp_file.flush() + + # Set our minimum requirements for a successful download() call + self.download_path = self._temp_file.name + if not self.detected_name: + self.detected_name = os.path.basename(self.fullpath) + + except requests.RequestException as e: + self.logger.error( + 'A Connection error occurred retrieving HTTP ' + 'configuration from %s.' % self.host) + self.logger.debug('Socket Exception: %s' % str(e)) + + # Invalidate any variables previously set + self.invalidate() + + # Return False (signifying a failure) + return False + + except (IOError, OSError): + # IOError is present for backwards compatibility with Python + # versions older then 3.3. >= 3.3 throw OSError now. + + # Could not open and/or write the temporary file + self.logger.error( + 'Could not write attachment to disk: {}'.format( + self.url(privacy=True))) + + # Invalidate any variables previously set + self.invalidate() + + # Return False (signifying a failure) + return False + + # Return our success + return True + + def invalidate(self): + """ + Close our temporary file + """ + if self._temp_file: + self._temp_file.close() + self._temp_file = None + + super().invalidate() + + def url(self, privacy=False, *args, **kwargs): + """ + Returns the URL built dynamically based on specified arguments. + """ + + # Our URL parameters + params = self.url_parameters(privacy=privacy, *args, **kwargs) + + # Prepare our cache value + if self.cache is not None: + if isinstance(self.cache, bool) or not self.cache: + cache = 'yes' if self.cache else 'no' + else: + cache = int(self.cache) + + # Set our cache value + params['cache'] = cache + + if self._mimetype: + # A format was enforced + params['mime'] = self._mimetype + + if self._name: + # A name was enforced + params['name'] = self._name + + # Append our headers into our parameters + params.update({'+{}'.format(k): v for k, v in self.headers.items()}) + + # Apply any remaining entries to our URL + params.update(self.qsd) + + # Determine Authentication + auth = '' + if self.user and self.password: + auth = '{user}:{password}@'.format( + user=self.quote(self.user, safe=''), + password=self.pprint( + self.password, privacy, mode=PrivacyMode.Secret, safe=''), + ) + elif self.user: + auth = '{user}@'.format( + user=self.quote(self.user, safe=''), + ) + + default_port = 443 if self.secure else 80 + + return '{schema}://{auth}{hostname}{port}{fullpath}?{params}'.format( + schema=self.secure_protocol if self.secure else self.protocol, + auth=auth, + hostname=self.quote(self.host, safe=''), + port='' if self.port is None or self.port == default_port + else ':{}'.format(self.port), + fullpath=self.quote(self.fullpath, safe='/'), + params=self.urlencode(params), + ) + + @staticmethod + def parse_url(url): + """ + Parses the URL and returns enough arguments that can allow + us to re-instantiate this object. + + """ + results = AttachBase.parse_url(url) + + if not results: + # We're done early as we couldn't load the results + return results + + # Add our headers that the user can potentially over-ride if they wish + # to to our returned result set + results['headers'] = results['qsd-'] + results['headers'].update(results['qsd+']) + + return results diff --git a/lib/apprise/attachment/__init__.py b/lib/apprise/attachment/__init__.py new file mode 100644 index 0000000..ba7620a --- /dev/null +++ b/lib/apprise/attachment/__init__.py @@ -0,0 +1,118 @@ +# -*- 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 +from os import listdir +from os.path import dirname +from os.path import abspath +from ..common import ATTACHMENT_SCHEMA_MAP + +__all__ = [] + + +# Load our Lookup Matrix +def __load_matrix(path=abspath(dirname(__file__)), name='apprise.attachment'): + """ + Dynamically load our schema map; this allows us to gracefully + skip over modules we simply don't have the dependencies for. + + """ + # Used for the detection of additional Attachment Services objects + # The .py extension is optional as we support loading directories too + module_re = re.compile(r'^(?PAttach[a-z0-9]+)(\.py)?$', re.I) + + for f in listdir(path): + match = module_re.match(f) + if not match: + # keep going + continue + + # Store our notification/plugin name: + plugin_name = match.group('name') + try: + module = __import__( + '{}.{}'.format(name, plugin_name), + globals(), locals(), + fromlist=[plugin_name]) + + except ImportError: + # No problem, we can't use this object + continue + + if not hasattr(module, plugin_name): + # Not a library we can load as it doesn't follow the simple rule + # that the class must bear the same name as the notification + # file itself. + continue + + # Get our plugin + plugin = getattr(module, plugin_name) + if not hasattr(plugin, 'app_id'): + # Filter out non-notification modules + continue + + elif plugin_name in __all__: + # we're already handling this object + continue + + # Add our module name to our __all__ + __all__.append(plugin_name) + + # Ensure we provide the class as the reference to this directory and + # not the module: + globals()[plugin_name] = plugin + + # Load protocol(s) if defined + proto = getattr(plugin, 'protocol', None) + if isinstance(proto, str): + if proto not in ATTACHMENT_SCHEMA_MAP: + ATTACHMENT_SCHEMA_MAP[proto] = plugin + + elif isinstance(proto, (set, list, tuple)): + # Support iterables list types + for p in proto: + if p not in ATTACHMENT_SCHEMA_MAP: + ATTACHMENT_SCHEMA_MAP[p] = plugin + + # Load secure protocol(s) if defined + protos = getattr(plugin, 'secure_protocol', None) + if isinstance(protos, str): + if protos not in ATTACHMENT_SCHEMA_MAP: + ATTACHMENT_SCHEMA_MAP[protos] = plugin + + if isinstance(protos, (set, list, tuple)): + # Support iterables list types + for p in protos: + if p not in ATTACHMENT_SCHEMA_MAP: + ATTACHMENT_SCHEMA_MAP[p] = plugin + + return ATTACHMENT_SCHEMA_MAP + + +# Dynamically build our schema base +__load_matrix() diff --git a/lib/apprise/cli.py b/lib/apprise/cli.py new file mode 100644 index 0000000..1303518 --- /dev/null +++ b/lib/apprise/cli.py @@ -0,0 +1,520 @@ +# -*- 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 click +import logging +import platform +import sys +import os +import re + +from os.path import isfile +from os.path import exists +from os.path import expanduser +from os.path import expandvars + +from . import NotifyType +from . import NotifyFormat +from . import Apprise +from . import AppriseAsset +from . import AppriseConfig + +from .utils import parse_list +from .common import NOTIFY_TYPES +from .common import NOTIFY_FORMATS +from .common import ContentLocation +from .logger import logger + +from . import __title__ +from . import __version__ +from . import __license__ +from . import __copywrite__ + +# By default we allow looking 1 level down recursivly in Apprise configuration +# files. +DEFAULT_RECURSION_DEPTH = 1 + +# Defines our click context settings adding -h to the additional options that +# can be specified to get the help menu to come up +CONTEXT_SETTINGS = dict(help_option_names=['-h', '--help']) + +# Define our default configuration we use if nothing is otherwise specified +DEFAULT_CONFIG_PATHS = ( + # Legacy Path Support + '~/.apprise', + '~/.apprise.yml', + '~/.config/apprise', + '~/.config/apprise.yml', + + # Plugin Support Extended Directory Search Paths + '~/.apprise/apprise', + '~/.apprise/apprise.yml', + '~/.config/apprise/apprise', + '~/.config/apprise/apprise.yml', + + # Global Configuration Support + '/etc/apprise', + '/etc/apprise.yml', + '/etc/apprise/apprise', + '/etc/apprise/apprise.yml', +) + +# Define our paths to search for plugins +DEFAULT_PLUGIN_PATHS = ( + '~/.apprise/plugins', + '~/.config/apprise/plugins', + + # Global Plugin Support + '/var/lib/apprise/plugins', +) + +# Detect Windows +if platform.system() == 'Windows': + # Default Config Search Path for Windows Users + DEFAULT_CONFIG_PATHS = ( + expandvars('%APPDATA%\\Apprise\\apprise'), + expandvars('%APPDATA%\\Apprise\\apprise.yml'), + expandvars('%LOCALAPPDATA%\\Apprise\\apprise'), + expandvars('%LOCALAPPDATA%\\Apprise\\apprise.yml'), + + # + # Global Support + # + + # C:\ProgramData\Apprise\ + expandvars('%ALLUSERSPROFILE%\\Apprise\\apprise'), + expandvars('%ALLUSERSPROFILE%\\Apprise\\apprise.yml'), + + # C:\Program Files\Apprise + expandvars('%PROGRAMFILES%\\Apprise\\apprise'), + expandvars('%PROGRAMFILES%\\Apprise\\apprise.yml'), + + # C:\Program Files\Common Files + expandvars('%COMMONPROGRAMFILES%\\Apprise\\apprise'), + expandvars('%COMMONPROGRAMFILES%\\Apprise\\apprise.yml'), + ) + + # Default Plugin Search Path for Windows Users + DEFAULT_PLUGIN_PATHS = ( + expandvars('%APPDATA%\\Apprise\\plugins'), + expandvars('%LOCALAPPDATA%\\Apprise\\plugins'), + + # + # Global Support + # + + # C:\ProgramData\Apprise\plugins + expandvars('%ALLUSERSPROFILE%\\Apprise\\plugins'), + # C:\Program Files\Apprise\plugins + expandvars('%PROGRAMFILES%\\Apprise\\plugins'), + # C:\Program Files\Common Files + expandvars('%COMMONPROGRAMFILES%\\Apprise\\plugins'), + ) + + +def print_help_msg(command): + """ + Prints help message when -h or --help is specified. + + """ + with click.Context(command) as ctx: + click.echo(command.get_help(ctx)) + + +def print_version_msg(): + """ + Prints version message when -V or --version is specified. + + """ + result = list() + result.append('{} v{}'.format(__title__, __version__)) + result.append(__copywrite__) + result.append( + 'This code is licensed under the {} License.'.format(__license__)) + click.echo('\n'.join(result)) + + +@click.command(context_settings=CONTEXT_SETTINGS) +@click.option('--body', '-b', default=None, type=str, + help='Specify the message body. If no body is specified then ' + 'content is read from .') +@click.option('--title', '-t', default=None, type=str, + help='Specify the message title. This field is complete ' + 'optional.') +@click.option('--plugin-path', '-P', default=None, type=str, multiple=True, + metavar='PLUGIN_PATH', + help='Specify one or more plugin paths to scan.') +@click.option('--config', '-c', default=None, type=str, multiple=True, + metavar='CONFIG_URL', + help='Specify one or more configuration locations.') +@click.option('--attach', '-a', default=None, type=str, multiple=True, + metavar='ATTACHMENT_URL', + help='Specify one or more attachment.') +@click.option('--notification-type', '-n', default=NotifyType.INFO, type=str, + metavar='TYPE', + help='Specify the message type (default={}). ' + 'Possible values are "{}", and "{}".'.format( + NotifyType.INFO, '", "'.join(NOTIFY_TYPES[:-1]), + NOTIFY_TYPES[-1])) +@click.option('--input-format', '-i', default=NotifyFormat.TEXT, type=str, + metavar='FORMAT', + help='Specify the message input format (default={}). ' + 'Possible values are "{}", and "{}".'.format( + NotifyFormat.TEXT, '", "'.join(NOTIFY_FORMATS[:-1]), + NOTIFY_FORMATS[-1])) +@click.option('--theme', '-T', default='default', type=str, metavar='THEME', + help='Specify the default theme.') +@click.option('--tag', '-g', default=None, type=str, multiple=True, + metavar='TAG', help='Specify one or more tags to filter ' + 'which services to notify. Use multiple --tag (-g) entries to ' + '"OR" the tags together and comma separated to "AND" them. ' + 'If no tags are specified then all services are notified.') +@click.option('--disable-async', '-Da', is_flag=True, + help='Send all notifications sequentially') +@click.option('--dry-run', '-d', is_flag=True, + help='Perform a trial run but only prints the notification ' + 'services to-be triggered to stdout. Notifications are never ' + 'sent using this mode.') +@click.option('--details', '-l', is_flag=True, + help='Prints details about the current services supported by ' + 'Apprise.') +@click.option('--recursion-depth', '-R', default=DEFAULT_RECURSION_DEPTH, + type=int, + help='The number of recursive import entries that can be ' + 'loaded from within Apprise configuration. By default ' + 'this is set to {}.'.format(DEFAULT_RECURSION_DEPTH)) +@click.option('--verbose', '-v', count=True, + help='Makes the operation more talkative. Use multiple v to ' + 'increase the verbosity. I.e.: -vvvv') +@click.option('--interpret-escapes', '-e', is_flag=True, + help='Enable interpretation of backslash escapes') +@click.option('--debug', '-D', is_flag=True, help='Debug mode') +@click.option('--version', '-V', is_flag=True, + help='Display the apprise version and exit.') +@click.argument('urls', nargs=-1, + metavar='SERVER_URL [SERVER_URL2 [SERVER_URL3]]',) +def main(body, title, config, attach, urls, notification_type, theme, tag, + input_format, dry_run, recursion_depth, verbose, disable_async, + details, interpret_escapes, plugin_path, debug, version): + """ + Send a notification to all of the specified servers identified by their + URLs the content provided within the title, body and notification-type. + + For a list of all of the supported services and information on how to + use them, check out at https://github.com/caronc/apprise + """ + # Note: Click ignores the return values of functions it wraps, If you + # want to return a specific error code, you must call sys.exit() + # as you will see below. + + debug = True if debug else False + if debug: + # Verbosity must be a minimum of 3 + verbose = 3 if verbose < 3 else verbose + + # Logging + ch = logging.StreamHandler(sys.stdout) + if verbose > 3: + # -vvvv: Most Verbose Debug Logging + logger.setLevel(logging.TRACE) + + elif verbose > 2: + # -vvv: Debug Logging + logger.setLevel(logging.DEBUG) + + elif verbose > 1: + # -vv: INFO Messages + logger.setLevel(logging.INFO) + + elif verbose > 0: + # -v: WARNING Messages + logger.setLevel(logging.WARNING) + + else: + # No verbosity means we display ERRORS only AND any deprecation + # warnings + logger.setLevel(logging.ERROR) + + # Format our logger + formatter = logging.Formatter('%(asctime)s - %(levelname)s - %(message)s') + ch.setFormatter(formatter) + logger.addHandler(ch) + + # Update our asyncio logger + asyncio_logger = logging.getLogger('asyncio') + for handler in logger.handlers: + asyncio_logger.addHandler(handler) + asyncio_logger.setLevel(logger.level) + + if version: + print_version_msg() + sys.exit(0) + + # Simple Error Checking + notification_type = notification_type.strip().lower() + if notification_type not in NOTIFY_TYPES: + logger.error( + 'The --notification-type (-n) value of {} is not supported.' + .format(notification_type)) + # 2 is the same exit code returned by Click if there is a parameter + # issue. For consistency, we also return a 2 + sys.exit(2) + + input_format = input_format.strip().lower() + if input_format not in NOTIFY_FORMATS: + logger.error( + 'The --input-format (-i) value of {} is not supported.' + .format(input_format)) + # 2 is the same exit code returned by Click if there is a parameter + # issue. For consistency, we also return a 2 + sys.exit(2) + + if not plugin_path: + # Prepare a default set of plugin path + plugin_path = \ + next((path for path in DEFAULT_PLUGIN_PATHS + if exists(expanduser(path))), None) + + # Prepare our asset + asset = AppriseAsset( + # Our body format + body_format=input_format, + + # Interpret Escapes + interpret_escapes=interpret_escapes, + + # Set the theme + theme=theme, + + # Async mode allows a user to send all of their notifications + # asynchronously. This was made an option incase there are problems + # in the future where it is better that everything runs sequentially/ + # synchronously instead. + async_mode=disable_async is not True, + + # Load our plugins + plugin_paths=plugin_path, + ) + + # Create our Apprise object + a = Apprise(asset=asset, debug=debug, location=ContentLocation.LOCAL) + + if details: + # Print details and exit + results = a.details(show_requirements=True, show_disabled=True) + + # Sort our results: + plugins = sorted( + results['schemas'], key=lambda i: str(i['service_name'])) + for entry in plugins: + protocols = [] if not entry['protocols'] else \ + [p for p in entry['protocols'] + if isinstance(p, str)] + protocols.extend( + [] if not entry['secure_protocols'] else + [p for p in entry['secure_protocols'] + if isinstance(p, str)]) + + if len(protocols) == 1: + # Simplify view by swapping {schema} with the single + # protocol value + + # Convert tuple to list + entry['details']['templates'] = \ + list(entry['details']['templates']) + + for x in range(len(entry['details']['templates'])): + entry['details']['templates'][x] = \ + re.sub( + r'^[^}]+}://', + '{}://'.format(protocols[0]), + entry['details']['templates'][x]) + + fg = "green" if entry['enabled'] else "red" + if entry['category'] == 'custom': + # Identify these differently + fg = "cyan" + # Flip the enable switch so it forces the requirements + # to be displayed + entry['enabled'] = False + + click.echo(click.style( + '{} {:<30} '.format( + '+' if entry['enabled'] else '-', + str(entry['service_name'])), fg=fg, bold=True), + nl=(not entry['enabled'] or len(protocols) == 1)) + + if not entry['enabled']: + if entry['requirements']['details']: + click.echo( + ' ' + str(entry['requirements']['details'])) + + if entry['requirements']['packages_required']: + click.echo(' Python Packages Required:') + for req in entry['requirements']['packages_required']: + click.echo(' - ' + req) + + if entry['requirements']['packages_recommended']: + click.echo(' Python Packages Recommended:') + for req in entry['requirements']['packages_recommended']: + click.echo(' - ' + req) + + # new line padding between entries + if entry['category'] == 'native': + click.echo() + continue + + if len(protocols) > 1: + click.echo('| Schema(s): {}'.format( + ', '.join(protocols), + )) + + prefix = ' - ' + click.echo('{}{}'.format( + prefix, + '\n{}'.format(prefix).join(entry['details']['templates']))) + + # new line padding between entries + click.echo() + + sys.exit(0) + # end if details() + + # The priorities of what is accepted are parsed in order below: + # 1. URLs by command line + # 2. Configuration by command line + # 3. URLs by environment variable: APPRISE_URLS + # 4. Configuration by environment variable: APPRISE_CONFIG + # 5. Default Configuration File(s) (if found) + # + if urls: + if tag: + # Ignore any tags specified + logger.warning( + '--tag (-g) entries are ignored when using specified URLs') + tag = None + + # Load our URLs (if any defined) + for url in urls: + a.add(url) + + if config: + # Provide a warning to the end user if they specified both + logger.warning( + 'You defined both URLs and a --config (-c) entry; ' + 'Only the URLs will be referenced.') + + elif config: + # We load our configuration file(s) now only if no URLs were specified + # Specified config entries trump all + a.add(AppriseConfig( + paths=config, asset=asset, recursion=recursion_depth)) + + elif os.environ.get('APPRISE_URLS', '').strip(): + logger.debug('Loading provided APPRISE_URLS environment variable') + if tag: + # Ignore any tags specified + logger.warning( + '--tag (-g) entries are ignored when using specified URLs') + tag = None + + # Attempt to use our APPRISE_URLS environment variable (if populated) + a.add(os.environ['APPRISE_URLS'].strip()) + + elif os.environ.get('APPRISE_CONFIG', '').strip(): + logger.debug('Loading provided APPRISE_CONFIG environment variable') + # Fall back to config environment variable (if populated) + a.add(AppriseConfig( + paths=os.environ['APPRISE_CONFIG'].strip(), + asset=asset, recursion=recursion_depth)) + + else: + # Load default configuration + a.add(AppriseConfig( + paths=[f for f in DEFAULT_CONFIG_PATHS if isfile(expanduser(f))], + asset=asset, recursion=recursion_depth)) + + if len(a) == 0 and not urls: + logger.error( + 'You must specify at least one server URL or populated ' + 'configuration file.') + print_help_msg(main) + sys.exit(1) + + # each --tag entry comprises of a comma separated 'and' list + # we or each of of the --tag and sets specified. + tags = None if not tag else [parse_list(t) for t in tag] + + if not dry_run: + if body is None: + logger.trace('No --body (-b) specified; reading from stdin') + # if no body was specified, then read from STDIN + body = click.get_text_stream('stdin').read() + + # now print it out + result = a.notify( + body=body, title=title, notify_type=notification_type, tag=tags, + attach=attach) + else: + # Number of rows to assume in the terminal. In future, maybe this can + # be detected and made dynamic. The actual row count is 80, but 5 + # characters are already reserved for the counter on the left + rows = 75 + + # Initialize our URL response; This is populated within the for/loop + # below; but plays a factor at the end when we need to determine if + # we iterated at least once in the loop. + url = None + + for idx, server in enumerate(a.find(tag=tags)): + url = server.url(privacy=True) + click.echo("{: 3d}. {}".format( + idx + 1, + url if len(url) <= rows else '{}...'.format(url[:rows - 3]))) + if server.tags: + click.echo("{} - {}".format(' ' * 5, ', '.join(server.tags))) + + # Initialize a default response of nothing matched, otherwise + # if we matched at least one entry, we can return True + result = None if url is None else True + + if result is None: + # There were no notifications set. This is a result of just having + # empty configuration files and/or being to restrictive when filtering + # by specific tag(s) + + # Exit code 3 is used since Click uses exit code 2 if there is an + # error with the parameters specified + sys.exit(3) + + elif result is False: + # At least 1 notification service failed to send + sys.exit(1) + + # else: We're good! + sys.exit(0) diff --git a/lib/apprise/common.py b/lib/apprise/common.py new file mode 100644 index 0000000..aaf746e --- /dev/null +++ b/lib/apprise/common.py @@ -0,0 +1,240 @@ +# -*- 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. + +# we mirror our base purely for the ability to reset everything; this +# is generally only used in testing and should not be used by developers +# It is also used as a means of preventing a module from being reloaded +# in the event it already exists +NOTIFY_MODULE_MAP = {} + +# Maintains a mapping of all of the Notification services +NOTIFY_SCHEMA_MAP = {} + +# This contains a mapping of all plugins dynamicaly loaded at runtime from +# external modules such as the @notify decorator +# +# The elements here will be additionally added to the NOTIFY_SCHEMA_MAP if +# there is no conflict otherwise. +# The structure looks like the following: +# Module path, e.g. /usr/share/apprise/plugins/my_notify_hook.py +# { +# 'path': path, +# +# 'notify': { +# 'schema': { +# 'name': 'Custom schema name', +# 'fn_name': 'name_of_function_decorator_was_found_on', +# 'url': 'schema://any/additional/info/found/on/url' +# 'plugin': +# }, +# 'schema2': { +# 'name': 'Custom schema name', +# 'fn_name': 'name_of_function_decorator_was_found_on', +# 'url': 'schema://any/additional/info/found/on/url' +# 'plugin': +# } +# } +# +# Note: that the inherits from +# NotifyBase +NOTIFY_CUSTOM_MODULE_MAP = {} + +# Maintains a mapping of all configuration schema's supported +CONFIG_SCHEMA_MAP = {} + +# Maintains a mapping of all attachment schema's supported +ATTACHMENT_SCHEMA_MAP = {} + + +class NotifyType: + """ + A simple mapping of notification types most commonly used with + all types of logging and notification services. + """ + INFO = 'info' + SUCCESS = 'success' + WARNING = 'warning' + FAILURE = 'failure' + + +NOTIFY_TYPES = ( + NotifyType.INFO, + NotifyType.SUCCESS, + NotifyType.WARNING, + NotifyType.FAILURE, +) + + +class NotifyImageSize: + """ + A list of pre-defined image sizes to make it easier to work with defined + plugins. + """ + XY_32 = '32x32' + XY_72 = '72x72' + XY_128 = '128x128' + XY_256 = '256x256' + + +NOTIFY_IMAGE_SIZES = ( + NotifyImageSize.XY_32, + NotifyImageSize.XY_72, + NotifyImageSize.XY_128, + NotifyImageSize.XY_256, +) + + +class NotifyFormat: + """ + A list of pre-defined text message formats that can be passed via the + apprise library. + """ + TEXT = 'text' + HTML = 'html' + MARKDOWN = 'markdown' + + +NOTIFY_FORMATS = ( + NotifyFormat.TEXT, + NotifyFormat.HTML, + NotifyFormat.MARKDOWN, +) + + +class OverflowMode: + """ + A list of pre-defined modes of how to handle the text when it exceeds the + defined maximum message size. + """ + + # Send the data as is; untouched. Let the upstream server decide how the + # content is handled. Some upstream services might gracefully handle this + # with expected intentions; others might not. + UPSTREAM = 'upstream' + + # Always truncate the text when it exceeds the maximum message size and + # send it anyway + TRUNCATE = 'truncate' + + # Split the message into multiple smaller messages that fit within the + # limits of what is expected. The smaller messages are sent + SPLIT = 'split' + + +# Define our modes so we can verify if we need to +OVERFLOW_MODES = ( + OverflowMode.UPSTREAM, + OverflowMode.TRUNCATE, + OverflowMode.SPLIT, +) + + +class ConfigFormat: + """ + A list of pre-defined config formats that can be passed via the + apprise library. + """ + + # A text based configuration. This consists of a list of URLs delimited by + # a new line. pound/hashtag (#) or semi-colon (;) can be used as comment + # characters. + TEXT = 'text' + + # YAML files allow a more rich of an experience when settig up your + # apprise configuration files. + YAML = 'yaml' + + +# Define our configuration formats mostly used for verification +CONFIG_FORMATS = ( + ConfigFormat.TEXT, + ConfigFormat.YAML, +) + + +class ContentIncludeMode: + """ + The different Content inclusion modes. All content based plugins will + have one of these associated with it. + """ + # - Content inclusion of same type only; hence a file:// can include + # a file:// + # - Cross file inclusion is not allowed unless insecure_includes (a flag) + # is set to True. In these cases STRICT acts as type ALWAYS + STRICT = 'strict' + + # This content type can never be included + NEVER = 'never' + + # This content can always be included + ALWAYS = 'always' + + +CONTENT_INCLUDE_MODES = ( + ContentIncludeMode.STRICT, + ContentIncludeMode.NEVER, + ContentIncludeMode.ALWAYS, +) + + +class ContentLocation: + """ + This is primarily used for handling file attachments. The idea is + to track the source of the attachment itself. We don't want + remote calls to a server to access local attachments for example. + + By knowing the attachment type and cross-associating it with how + we plan on accessing the content, we can make a judgement call + (for security reasons) if we will allow it. + + Obviously local uses of apprise can access both local and remote + type files. + """ + # Content is located locally (on the same server as apprise) + LOCAL = 'local' + + # Content is located in a remote location + HOSTED = 'hosted' + + # Content is inaccessible + INACCESSIBLE = 'n/a' + + +CONTENT_LOCATIONS = ( + ContentLocation.LOCAL, + ContentLocation.HOSTED, + ContentLocation.INACCESSIBLE, +) + +# This is a reserved tag that is automatically assigned to every +# Notification Plugin +MATCH_ALL_TAG = 'all' + +# Will cause notification to trigger under any circumstance even if an +# exclusive tagging was provided. +MATCH_ALWAYS_TAG = 'always' diff --git a/lib/apprise/common.pyi b/lib/apprise/common.pyi new file mode 100644 index 0000000..862fc4f --- /dev/null +++ b/lib/apprise/common.pyi @@ -0,0 +1,22 @@ +import types +import typing as t + + +class NotifyType: + INFO: NotifyType + SUCCESS: NotifyType + WARNING: NotifyType + FAILURE: NotifyType + +class NotifyFormat: + TEXT: NotifyFormat + HTML: NotifyFormat + MARKDOWN: NotifyFormat + +class ContentLocation: + LOCAL: ContentLocation + HOSTED: ContentLocation + INACCESSIBLE: ContentLocation + + +NOTIFY_MODULE_MAP: t.Dict[str, t.Dict[str, t.Union[t.Type["NotifyBase"], types.ModuleType]]] diff --git a/lib/apprise/config/ConfigBase.py b/lib/apprise/config/ConfigBase.py new file mode 100644 index 0000000..adddc4f --- /dev/null +++ b/lib/apprise/config/ConfigBase.py @@ -0,0 +1,1391 @@ +# -*- 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 re +import yaml +import time + +from .. import plugins +from .. import common +from ..AppriseAsset import AppriseAsset +from ..URLBase import URLBase +from ..utils import GET_SCHEMA_RE +from ..utils import parse_list +from ..utils import parse_bool +from ..utils import parse_urls +from ..utils import cwe312_url + +# Test whether token is valid or not +VALID_TOKEN = re.compile( + r'(?P[a-z0-9][a-z0-9_]+)', re.I) + + +class ConfigBase(URLBase): + """ + This is the base class for all supported configuration sources + """ + + # The Default Encoding to use if not otherwise detected + encoding = 'utf-8' + + # The default expected configuration format unless otherwise + # detected by the sub-modules + default_config_format = common.ConfigFormat.TEXT + + # This is only set if the user overrides the config format on the URL + # this should always initialize itself as None + config_format = None + + # Don't read any more of this amount of data into memory as there is no + # reason we should be reading in more. This is more of a safe guard then + # anything else. 128KB (131072B) + max_buffer_size = 131072 + + # By default all configuration is not includable using the 'include' + # line found in configuration files. + allow_cross_includes = common.ContentIncludeMode.NEVER + + # the config path manages the handling of relative include + config_path = os.getcwd() + + def __init__(self, cache=True, recursion=0, insecure_includes=False, + **kwargs): + """ + Initialize some general logging and common server arguments that will + keep things consistent when working with the configurations that + inherit this class. + + By default we cache our responses so that subsiquent calls does not + cause the content to be retrieved again. For local file references + this makes no difference at all. But for remote content, this does + mean more then one call can be made to retrieve the (same) data. This + method can be somewhat inefficient if disabled. Only disable caching + if you understand the consequences. + + You can alternatively set the cache value to an int identifying the + number of seconds the previously retrieved can exist for before it + should be considered expired. + + recursion defines how deep we recursively handle entries that use the + `include` keyword. This keyword requires us to fetch more configuration + from another source and add it to our existing compilation. If the + file we remotely retrieve also has an `include` reference, we will only + advance through it if recursion is set to 2 deep. If set to zero + it is off. There is no limit to how high you set this value. It would + be recommended to keep it low if you do intend to use it. + + insecure_include by default are disabled. When set to True, all + Apprise Config files marked to be in STRICT mode are treated as being + in ALWAYS mode. + + Take a file:// based configuration for example, only a file:// based + configuration can include another file:// based one. because it is set + to STRICT mode. If an http:// based configuration file attempted to + include a file:// one it woul fail. However this include would be + possible if insecure_includes is set to True. + + There are cases where a self hosting apprise developer may wish to load + configuration from memory (in a string format) that contains 'include' + entries (even file:// based ones). In these circumstances if you want + these 'include' entries to be honored, this value must be set to True. + """ + + super().__init__(**kwargs) + + # Tracks the time the content was last retrieved on. This place a role + # for cases where we are not caching our response and are required to + # re-retrieve our settings. + self._cached_time = None + + # Tracks previously loaded content for speed + self._cached_servers = None + + # Initialize our recursion value + self.recursion = recursion + + # Initialize our insecure_includes flag + self.insecure_includes = insecure_includes + + if 'encoding' in kwargs: + # Store the encoding + self.encoding = kwargs.get('encoding') + + if 'format' in kwargs \ + and isinstance(kwargs['format'], str): + # Store the enforced config format + self.config_format = kwargs.get('format').lower() + + if self.config_format not in common.CONFIG_FORMATS: + # Simple error checking + err = 'An invalid config format ({}) was specified.'.format( + self.config_format) + self.logger.warning(err) + raise TypeError(err) + + # Set our cache flag; it can be True or a (positive) integer + try: + self.cache = cache if isinstance(cache, bool) else int(cache) + if self.cache < 0: + err = 'A negative cache value ({}) was specified.'.format( + cache) + self.logger.warning(err) + raise TypeError(err) + + except (ValueError, TypeError): + err = 'An invalid cache value ({}) was specified.'.format(cache) + self.logger.warning(err) + raise TypeError(err) + + return + + def servers(self, asset=None, **kwargs): + """ + Performs reads loaded configuration and returns all of the services + that could be parsed and loaded. + + """ + + if not self.expired(): + # We already have cached results to return; use them + return self._cached_servers + + # Our cached response object + self._cached_servers = list() + + # read() causes the child class to do whatever it takes for the + # config plugin to load the data source and return unparsed content + # None is returned if there was an error or simply no data + content = self.read(**kwargs) + if not isinstance(content, str): + # Set the time our content was cached at + self._cached_time = time.time() + + # Nothing more to do; return our empty cache list + return self._cached_servers + + # Our Configuration format uses a default if one wasn't one detected + # or enfored. + config_format = \ + self.default_config_format \ + if self.config_format is None else self.config_format + + # Dynamically load our parse_ function based on our config format + fn = getattr(ConfigBase, 'config_parse_{}'.format(config_format)) + + # Initialize our asset object + asset = asset if isinstance(asset, AppriseAsset) else self.asset + + # Execute our config parse function which always returns a tuple + # of our servers and our configuration + servers, configs = fn(content=content, asset=asset) + self._cached_servers.extend(servers) + + # Configuration files were detected; recursively populate them + # If we have been configured to do so + for url in configs: + + if self.recursion > 0: + # Attempt to acquire the schema at the very least to allow + # our configuration based urls. + schema = GET_SCHEMA_RE.match(url) + if schema is None: + # Plan B is to assume we're dealing with a file + schema = 'file' + if not os.path.isabs(url): + # We're dealing with a relative path; prepend + # our current config path + url = os.path.join(self.config_path, url) + + url = '{}://{}'.format(schema, URLBase.quote(url)) + + else: + # Ensure our schema is always in lower case + schema = schema.group('schema').lower() + + # Some basic validation + if schema not in common.CONFIG_SCHEMA_MAP: + ConfigBase.logger.warning( + 'Unsupported include schema {}.'.format(schema)) + continue + + # CWE-312 (Secure Logging) Handling + loggable_url = url if not asset.secure_logging \ + else cwe312_url(url) + + # Parse our url details of the server object as dictionary + # containing all of the information parsed from our URL + results = common.CONFIG_SCHEMA_MAP[schema].parse_url(url) + if not results: + # Failed to parse the server URL + self.logger.warning( + 'Unparseable include URL {}'.format(loggable_url)) + continue + + # Handle cross inclusion based on allow_cross_includes rules + if (common.CONFIG_SCHEMA_MAP[schema].allow_cross_includes == + common.ContentIncludeMode.STRICT + and schema not in self.schemas() + and not self.insecure_includes) or \ + common.CONFIG_SCHEMA_MAP[schema] \ + .allow_cross_includes == \ + common.ContentIncludeMode.NEVER: + + # Prevent the loading if insecure base protocols + ConfigBase.logger.warning( + 'Including {}:// based configuration is prohibited. ' + 'Ignoring URL {}'.format(schema, loggable_url)) + continue + + # Prepare our Asset Object + results['asset'] = asset + + # No cache is required because we're just lumping this in + # and associating it with the cache value we've already + # declared (prior to our recursion) + results['cache'] = False + + # Recursion can never be parsed from the URL; we decrement + # it one level + results['recursion'] = self.recursion - 1 + + # Insecure Includes flag can never be parsed from the URL + results['insecure_includes'] = self.insecure_includes + + try: + # Attempt to create an instance of our plugin using the + # parsed URL information + cfg_plugin = \ + common.CONFIG_SCHEMA_MAP[results['schema']](**results) + + except Exception as e: + # the arguments are invalid or can not be used. + self.logger.warning( + 'Could not load include URL: {}'.format(loggable_url)) + self.logger.debug('Loading Exception: {}'.format(str(e))) + continue + + # if we reach here, we can now add this servers found + # in this configuration file to our list + self._cached_servers.extend( + cfg_plugin.servers(asset=asset)) + + # We no longer need our configuration object + del cfg_plugin + + else: + # CWE-312 (Secure Logging) Handling + loggable_url = url if not asset.secure_logging \ + else cwe312_url(url) + + self.logger.debug( + 'Recursion limit reached; ignoring Include URL: %s', + loggable_url) + + if self._cached_servers: + self.logger.info( + 'Loaded {} entries from {}'.format( + len(self._cached_servers), + self.url(privacy=asset.secure_logging))) + else: + self.logger.warning( + 'Failed to load Apprise configuration from {}'.format( + self.url(privacy=asset.secure_logging))) + + # Set the time our content was cached at + self._cached_time = time.time() + + return self._cached_servers + + def read(self): + """ + This object should be implimented by the child classes + + """ + return None + + def expired(self): + """ + Simply returns True if the configuration should be considered + as expired or False if content should be retrieved. + """ + if isinstance(self._cached_servers, list) and self.cache: + # We have enough reason to look further into our cached content + # and verify it has not expired. + if self.cache is True: + # we have not expired, return False + return False + + # Verify our cache time to determine whether we will get our + # content again. + age_in_sec = time.time() - self._cached_time + if age_in_sec <= self.cache: + # We have not expired; return False + return False + + # If we reach here our configuration should be considered + # 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. + + This is very specific and customized for Apprise. + + Args: + url (str): The URL you want to fully parse. + verify_host (:obj:`bool`, optional): a flag kept with the parsed + URL which some child classes will later use to verify SSL + keys (if SSL transactions take place). Unless under very + specific circumstances, it is strongly recomended that + you leave this default value set to True. + + Returns: + A dictionary is returned containing the URL fully parsed if + successful, otherwise None is returned. + """ + + results = URLBase.parse_url(url, verify_host=verify_host) + + if not results: + # We're done; we failed to parse our url + return results + + # Allow overriding the default config format + if 'format' in results['qsd']: + results['format'] = results['qsd'].get('format') + if results['format'] not in common.CONFIG_FORMATS: + URLBase.logger.warning( + 'Unsupported format specified {}'.format( + results['format'])) + del results['format'] + + # Defines the encoding of the payload + if 'encoding' in results['qsd']: + results['encoding'] = results['qsd'].get('encoding') + + # Our cache value + if 'cache' in results['qsd']: + # First try to get it's integer value + try: + results['cache'] = int(results['qsd']['cache']) + + except (ValueError, TypeError): + # No problem, it just isn't an integer; now treat it as a bool + # instead: + results['cache'] = parse_bool(results['qsd']['cache']) + + return results + + @staticmethod + def detect_config_format(content, **kwargs): + """ + Takes the specified content and attempts to detect the format type + + The function returns the actual format type if detected, otherwise + it returns None + """ + + # Detect Format Logic: + # - A pound/hashtag (#) is alawys a comment character so we skip over + # lines matched here. + # - Detection begins on the first non-comment and non blank line + # matched. + # - If we find a string followed by a colon, we know we're dealing + # with a YAML file. + # - If we find a string that starts with a URL, or our tag + # definitions (accepting commas) followed by an equal sign we know + # we're dealing with a TEXT format. + + # Define what a valid line should look like + valid_line_re = re.compile( + r'^\s*(?P([;#]+(?P.*))|' + r'(?P((?P[ \t,a-z0-9_-]+)=)?[a-z0-9]+://.*)|' + r'((?P[a-z0-9]+):.*))?$', re.I) + + try: + # split our content up to read line by line + content = re.split(r'\r*\n', content) + + except TypeError: + # content was not expected string type + ConfigBase.logger.error( + 'Invalid Apprise configuration specified.') + return None + + # By default set our return value to None since we don't know + # what the format is yet + config_format = None + + # iterate over each line of the file to attempt to detect it + # stop the moment a the type has been determined + for line, entry in enumerate(content, start=1): + + result = valid_line_re.match(entry) + if not result: + # Invalid syntax + ConfigBase.logger.error( + 'Undetectable Apprise configuration found ' + 'based on line {}.'.format(line)) + # Take an early exit + return None + + # Attempt to detect configuration + if result.group('yaml'): + config_format = common.ConfigFormat.YAML + ConfigBase.logger.debug( + 'Detected YAML configuration ' + 'based on line {}.'.format(line)) + break + + elif result.group('text'): + config_format = common.ConfigFormat.TEXT + ConfigBase.logger.debug( + 'Detected TEXT configuration ' + 'based on line {}.'.format(line)) + break + + # If we reach here, we have a comment entry + # Adjust default format to TEXT + config_format = common.ConfigFormat.TEXT + + return config_format + + @staticmethod + def config_parse(content, asset=None, config_format=None, **kwargs): + """ + Takes the specified config content and loads it based on the specified + config_format. If a format isn't specified, then it is auto detected. + + """ + + if config_format is None: + # Detect the format + config_format = ConfigBase.detect_config_format(content) + + if not config_format: + # We couldn't detect configuration + ConfigBase.logger.error('Could not detect configuration') + return (list(), list()) + + if config_format not in common.CONFIG_FORMATS: + # Invalid configuration type specified + ConfigBase.logger.error( + 'An invalid configuration format ({}) was specified'.format( + config_format)) + return (list(), list()) + + # Dynamically load our parse_ function based on our config format + fn = getattr(ConfigBase, 'config_parse_{}'.format(config_format)) + + # Execute our config parse function which always returns a list + return fn(content=content, asset=asset) + + @staticmethod + def config_parse_text(content, asset=None): + """ + Parse the specified content as though it were a simple text file only + containing a list of URLs. + + Return a tuple that looks like (servers, configs) where: + - servers contains a list of loaded notification plugins + - configs contains a list of additional configuration files + referenced. + + You may also optionally associate an asset with the notification. + + The file syntax is: + + # + # pound/hashtag allow for line comments + # + # One or more tags can be idenified using comma's (,) to separate + # them. + = + + # Or you can use this format (no tags associated) + + + # you can also use the keyword 'include' and identify a + # configuration location (like this file) which will be included + # as additional configuration entries when loaded. + include + + # Assign tag contents to a group identifier + = + + """ + # A list of loaded Notification Services + servers = list() + + # A list of additional configuration files referenced using + # 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() + + # Define what a valid line should look like + valid_line_re = re.compile( + r'^\s*(?P([;#]+(?P.*))|' + r'(\s*(?P[a-z0-9, \t_-]+)\s*=|=)?\s*' + r'((?P[a-z0-9]{1,12}://.*)|(?P[a-z0-9, \t_-]+))|' + r'include\s+(?P.+))?\s*$', re.I) + + try: + # split our content up to read line by line + content = re.split(r'\r*\n', content) + + except TypeError: + # content was not expected string type + ConfigBase.logger.error( + 'Invalid Apprise TEXT based configuration specified.') + return (list(), list()) + + for line, entry in enumerate(content, start=1): + result = valid_line_re.match(entry) + if not result: + # Invalid syntax + ConfigBase.logger.error( + 'Invalid Apprise TEXT configuration format found ' + '{} on line {}.'.format(entry, line)) + + # Assume this is a file we shouldn't be parsing. It's owner + # can read the error printed to screen and take action + # otherwise. + return (list(), list()) + + # 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 + + if config: + # CWE-312 (Secure Logging) Handling + loggable_url = config if not asset.secure_logging \ + else cwe312_url(config) + + ConfigBase.logger.debug( + 'Include URL: {}'.format(loggable_url)) + + # Store our include line + configs.append(config.strip()) + continue + + # CWE-312 (Secure Logging) Handling + 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) + if results is None: + # Failed to parse the server URL + ConfigBase.logger.warning( + 'Unparseable URL {} on line {}.'.format( + loggable_url, line)) + continue + + # Build a list of tags to associate with the newly added + # notifications if any were set + 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) + + # Create log entry of loaded URL + ConfigBase.logger.debug( + '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( + entry['loggable_url'], entry['line'])) + ConfigBase.logger.debug('Loading Exception: %s' % str(e)) + continue + + # if we reach here, we successfully loaded our data + servers.append(plugin) + + # Return what was loaded + return (servers, configs) + + @staticmethod + def config_parse_yaml(content, asset=None): + """ + Parse the specified content as though it were a yaml file + specifically formatted for Apprise. + + Return a tuple that looks like (servers, configs) where: + - servers contains a list of loaded notification plugins + - configs contains a list of additional configuration files + referenced. + + You may optionally associate an asset with the notification. + + """ + + # A list of loaded Notification Services + servers = list() + + # A list of additional configuration files referenced using + # the include keyword + configs = list() + + # Group Assignments + group_tags = {} + + # Track our entries to preload + preloaded = [] + + try: + # Load our data (safely) + result = yaml.load(content, Loader=yaml.SafeLoader) + + except (AttributeError, + yaml.parser.ParserError, + yaml.error.MarkedYAMLError) as e: + # Invalid content + ConfigBase.logger.error( + 'Invalid Apprise YAML data specified.') + ConfigBase.logger.debug( + 'YAML Exception:{}{}'.format(os.linesep, e)) + return (list(), list()) + + if not isinstance(result, dict): + # Invalid content + ConfigBase.logger.error( + 'Invalid Apprise YAML based configuration specified.') + return (list(), list()) + + # YAML Version + version = result.get('version', 1) + if version != 1: + # Invalid syntax + ConfigBase.logger.error( + 'Invalid Apprise YAML version specified {}.'.format(version)) + return (list(), list()) + + # + # global asset object + # + asset = asset if isinstance(asset, AppriseAsset) else AppriseAsset() + tokens = result.get('asset', None) + if tokens and isinstance(tokens, dict): + for k, v in tokens.items(): + + if k.startswith('_') or k.endswith('_'): + # Entries are considered reserved if they start or end + # with an underscore + ConfigBase.logger.warning( + 'Ignored asset key "{}".'.format(k)) + continue + + if not (hasattr(asset, k) and + isinstance(getattr(asset, k), + (bool, str))): + + # We can't set a function or non-string set value + ConfigBase.logger.warning( + 'Invalid asset key "{}".'.format(k)) + continue + + if v is None: + # Convert to an empty string + v = '' + + if (isinstance(v, (bool, str)) + and isinstance(getattr(asset, k), bool)): + + # If the object in the Asset is a boolean, then + # we want to convert the specified string to + # match that. + setattr(asset, k, parse_bool(v)) + + elif isinstance(v, str): + # Set our asset object with the new value + setattr(asset, k, v.strip()) + + else: + # we must set strings with a string + ConfigBase.logger.warning( + 'Invalid asset value to "{}".'.format(k)) + continue + # + # global tag root directive + # + global_tags = set() + + tags = result.get('tag', None) + if tags and isinstance(tags, (list, tuple, str)): + # Store any preset tags + global_tags = set(parse_list(tags, cast=str)) + + # + # groups root directive + # + groups = result.get('groups', None) + if not isinstance(groups, (list, tuple)): + # Not a problem; we simply have no group entry + groups = list() + + # Iterate over each group defined and store it + for no, entry in enumerate(groups): + if not isinstance(entry, dict): + ConfigBase.logger.warning( + 'No assignment for group {}, entry #{}'.format( + entry, no + 1)) + continue + + for _groups, tags in entry.items(): + for group in parse_list(_groups, cast=str): + if isinstance(tags, (list, tuple)): + _tags = set() + for e in tags: + if isinstance(e, dict): + _tags |= set(e.keys()) + else: + _tags |= set(parse_list(e, cast=str)) + + # Final assignment + tags = _tags + + else: + tags = set(parse_list(tags, cast=str)) + + if group not in group_tags: + group_tags[group] = tags + + else: + group_tags[group] |= tags + + # + # include root directive + # + includes = result.get('include', None) + if isinstance(includes, str): + # Support a single inline string or multiple ones separated by a + # comma and/or space + includes = parse_urls(includes) + + elif not isinstance(includes, (list, tuple)): + # Not a problem; we simply have no includes + includes = list() + + # Iterate over each config URL + for no, url in enumerate(includes): + + if isinstance(url, str): + # Support a single inline string or multiple ones separated by + # a comma and/or space + configs.extend(parse_urls(url)) + + elif isinstance(url, dict): + # Store the url and ignore arguments associated + configs.extend(u for u in url.keys()) + + # + # urls root directive + # + urls = result.get('urls', None) + if not isinstance(urls, (list, tuple)): + # Not a problem; we simply have no urls + urls = list() + + # Iterate over each URL + for no, url in enumerate(urls): + + # Our results object is what we use to instantiate our object if + # we can. Reset it to None on each iteration + results = list() + + # CWE-312 (Secure Logging) Handling + loggable_url = url if not asset.secure_logging \ + else cwe312_url(url) + + if isinstance(url, str): + # We're just a simple URL string... + schema = GET_SCHEMA_RE.match(url) + if schema is None: + # Log invalid entries so that maintainer of config + # config file at least has something to take action + # with. + ConfigBase.logger.warning( + 'Invalid URL {}, entry #{}'.format( + loggable_url, no + 1)) + continue + + # We found a valid schema worthy of tracking; store it's + # details: + _results = plugins.url_to_dict( + url, secure_logging=asset.secure_logging) + if _results is None: + ConfigBase.logger.warning( + 'Unparseable URL {}, entry #{}'.format( + loggable_url, no + 1)) + continue + + # add our results to our global set + results.append(_results) + + elif isinstance(url, dict): + # We are a url string with additional unescaped options. In + # this case we want to iterate over all of our options so we + # can at least tell the end user what entries were ignored + # due to errors + + it = iter(url.items()) + + # Track the URL to-load + _url = None + + # Track last acquired schema + schema = None + for key, tokens in it: + # Test our schema + _schema = GET_SCHEMA_RE.match(key) + if _schema is None: + # Log invalid entries so that maintainer of config + # config file at least has something to take action + # with. + ConfigBase.logger.warning( + 'Ignored entry {} found under urls, entry #{}' + .format(key, no + 1)) + continue + + # Store our schema + schema = _schema.group('schema').lower() + + # Store our URL and Schema Regex + _url = key + + if _url is None: + # the loop above failed to match anything + ConfigBase.logger.warning( + 'Unsupported URL, entry #{}'.format(no + 1)) + continue + + _results = plugins.url_to_dict( + _url, secure_logging=asset.secure_logging) + if _results is None: + # Setup dictionary + _results = { + # Minimum requirements + 'schema': schema, + } + + if isinstance(tokens, (list, tuple, set)): + # populate and/or override any results populated by + # parse_url() + for entries in tokens: + # Copy ourselves a template of our parsed URL as a base + # to work with + r = _results.copy() + + # We are a url string with additional unescaped options + if isinstance(entries, dict): + _url, tokens = next(iter(url.items())) + + # Tags you just can't over-ride + if 'schema' in entries: + del entries['schema'] + + # support our special tokens (if they're present) + if schema in common.NOTIFY_SCHEMA_MAP: + entries = ConfigBase._special_token_handler( + schema, entries) + + # Extend our dictionary with our new entries + r.update(entries) + + # add our results to our global set + results.append(r) + + elif isinstance(tokens, dict): + # support our special tokens (if they're present) + if schema in common.NOTIFY_SCHEMA_MAP: + tokens = ConfigBase._special_token_handler( + schema, tokens) + + # Copy ourselves a template of our parsed URL as a base to + # work with + r = _results.copy() + + # add our result set + r.update(tokens) + + # add our results to our global set + results.append(r) + + else: + # add our results to our global set + results.append(_results) + + else: + # Unsupported + ConfigBase.logger.warning( + 'Unsupported Apprise YAML entry #{}'.format(no + 1)) + continue + + # Track our entries + entry = 0 + + while len(results): + # Increment our entry count + entry += 1 + + # Grab our first item + _results = results.pop(0) + + if _results['schema'] not in common.NOTIFY_SCHEMA_MAP: + # the arguments are invalid or can not be used. + ConfigBase.logger.warning( + 'An invalid Apprise schema ({}) in YAML configuration ' + 'entry #{}, item #{}' + .format(_results['schema'], no + 1, entry)) + continue + + # tag is a special keyword that is managed by Apprise object. + # The below ensures our tags are set correctly + if 'tag' in _results: + # Tidy our list up + _results['tag'] = set( + parse_list(_results['tag'], cast=str)) | global_tags + + else: + # Just use the global settings + _results['tag'] = global_tags + + for key in list(_results.keys()): + # Strip out any tokens we know that we can't accept and + # warn the user + match = VALID_TOKEN.match(key) + if not match: + ConfigBase.logger.warning( + 'Ignoring invalid token ({}) found in YAML ' + 'configuration entry #{}, item #{}' + .format(key, no + 1, entry)) + del _results[key] + + ConfigBase.logger.trace( + 'URL #{}: {} unpacked as:{}{}' + .format(no + 1, url, os.linesep, os.linesep.join( + ['{}="{}"'.format(k, a) + for k, a in _results.items()]))) + + # Prepare our Asset Object + _results['asset'] = asset + + # Store our preloaded entries + preloaded.append({ + 'results': _results, + 'entry': no + 1, + 'item': entry, + }) + + # + # 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) + + # Now we generate our plugin + try: + # Attempt to create an instance of our plugin using the + # parsed URL information + plugin = common.\ + NOTIFY_SCHEMA_MAP[results['schema']](**results) + + # Create log entry of loaded URL + ConfigBase.logger.debug( + '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 Apprise YAML configuration ' + 'entry #{}, item #{}' + .format(entry['entry'], entry['item'])) + ConfigBase.logger.debug('Loading Exception: %s' % str(e)) + continue + + # if we reach here, we successfully loaded our data + servers.append(plugin) + + return (servers, configs) + + def pop(self, index=-1): + """ + Removes an indexed Notification Service from the stack and returns it. + + By default, the last element of the list is removed. + """ + + if not isinstance(self._cached_servers, list): + # Generate ourselves a list of content we can pull from + self.servers() + + # Pop the element off of the stack + return self._cached_servers.pop(index) + + @staticmethod + def _special_token_handler(schema, tokens): + """ + This function takes a list of tokens and updates them to no longer + include any special tokens such as +,-, and : + + - schema must be a valid schema of a supported plugin type + - tokens must be a dictionary containing the yaml entries parsed. + + The idea here is we can post process a set of tokens provided in + a YAML file where the user provided some of the special keywords. + + We effectivley look up what these keywords map to their appropriate + value they're expected + """ + # Create a copy of our dictionary + tokens = tokens.copy() + + for kw, meta in common.NOTIFY_SCHEMA_MAP[schema]\ + .template_kwargs.items(): + + # Determine our prefix: + prefix = meta.get('prefix', '+') + + # Detect any matches + matches = \ + {k[1:]: str(v) for k, v in tokens.items() + if k.startswith(prefix)} + + if not matches: + # we're done with this entry + continue + + if not isinstance(tokens.get(kw), dict): + # Invalid; correct it + tokens[kw] = dict() + + # strip out processed tokens + tokens = {k: v for k, v in tokens.items() + if not k.startswith(prefix)} + + # Update our entries + tokens[kw].update(matches) + + # Now map our tokens accordingly to the class templates defined by + # each service. + # + # This is specifically used for YAML file parsing. It allows a user to + # define an entry such as: + # + # urls: + # - mailto://user:pass@domain: + # - to: user1@hotmail.com + # - to: user2@hotmail.com + # + # Under the hood, the NotifyEmail() class does not parse the `to` + # argument. It's contents needs to be mapped to `targets`. This is + # defined in the class via the `template_args` and template_tokens` + # section. + # + # This function here allows these mappings to take place within the + # YAML file as independant arguments. + class_templates = \ + plugins.details(common.NOTIFY_SCHEMA_MAP[schema]) + + for key in list(tokens.keys()): + + if key not in class_templates['args']: + # No need to handle non-arg entries + continue + + # get our `map_to` and/or 'alias_of' value (if it exists) + map_to = class_templates['args'][key].get( + 'alias_of', class_templates['args'][key].get('map_to', '')) + + if map_to == key: + # We're already good as we are now + continue + + if map_to in class_templates['tokens']: + meta = class_templates['tokens'][map_to] + + else: + meta = class_templates['args'].get( + map_to, class_templates['args'][key]) + + # Perform a translation/mapping if our code reaches here + value = tokens[key] + del tokens[key] + + # Detect if we're dealign with a list or not + is_list = re.search( + r'^list:.*', + meta.get('type'), + re.IGNORECASE) + + if map_to not in tokens: + tokens[map_to] = [] if is_list \ + else meta.get('default') + + elif is_list and not isinstance(tokens.get(map_to), list): + # Convert ourselves to a list if we aren't already + tokens[map_to] = [tokens[map_to]] + + # Type Conversion + if re.search( + r'^(choice:)?string', + meta.get('type'), + re.IGNORECASE) \ + and not isinstance(value, str): + + # Ensure our format is as expected + value = str(value) + + # Apply any further translations if required (absolute map) + # This is the case when an arg maps to a token which further + # maps to a different function arg on the class constructor + abs_map = meta.get('map_to', map_to) + + # Set our token as how it was provided by the configuration + if isinstance(tokens.get(map_to), list): + tokens[abs_map].append(value) + + else: + tokens[abs_map] = value + + # Return our tokens + return tokens + + def __getitem__(self, index): + """ + Returns the indexed server entry associated with the loaded + notification servers + """ + if not isinstance(self._cached_servers, list): + # Generate ourselves a list of content we can pull from + self.servers() + + return self._cached_servers[index] + + def __iter__(self): + """ + Returns an iterator to our server list + """ + if not isinstance(self._cached_servers, list): + # Generate ourselves a list of content we can pull from + self.servers() + + return iter(self._cached_servers) + + def __len__(self): + """ + Returns the total number of servers loaded + """ + if not isinstance(self._cached_servers, list): + # Generate ourselves a list of content we can pull from + self.servers() + + return len(self._cached_servers) + + def __bool__(self): + """ + Allows the Apprise object to be wrapped in an 'if statement'. + True is returned if our content was downloaded correctly. + """ + if not isinstance(self._cached_servers, list): + # Generate ourselves a list of content we can pull from + self.servers() + + return True if self._cached_servers else False diff --git a/lib/apprise/config/ConfigBase.pyi b/lib/apprise/config/ConfigBase.pyi new file mode 100644 index 0000000..abff120 --- /dev/null +++ b/lib/apprise/config/ConfigBase.pyi @@ -0,0 +1,3 @@ +from .. import URLBase + +class ConfigBase(URLBase): ... \ No newline at end of file diff --git a/lib/apprise/config/ConfigFile.py b/lib/apprise/config/ConfigFile.py new file mode 100644 index 0000000..7193551 --- /dev/null +++ b/lib/apprise/config/ConfigFile.py @@ -0,0 +1,177 @@ +# -*- 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 os +from .ConfigBase import ConfigBase +from ..common import ConfigFormat +from ..common import ContentIncludeMode +from ..AppriseLocale import gettext_lazy as _ + + +class ConfigFile(ConfigBase): + """ + A wrapper for File based configuration sources + """ + + # The default descriptive name associated with the service + service_name = _('Local File') + + # The default protocol + protocol = 'file' + + # Configuration file inclusion can only be of the same type + allow_cross_includes = ContentIncludeMode.STRICT + + def __init__(self, path, **kwargs): + """ + Initialize File 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) + + # Store our file path as it was set + self.path = os.path.abspath(os.path.expanduser(path)) + + # Update the config path to be relative to our file we just loaded + self.config_path = os.path.dirname(self.path) + + return + + def url(self, privacy=False, *args, **kwargs): + """ + Returns the URL built dynamically based on specified arguments. + """ + + # Prepare our cache value + if isinstance(self.cache, bool) or not self.cache: + cache = 'yes' if self.cache else 'no' + + else: + cache = int(self.cache) + + # Define any URL parameters + params = { + 'encoding': self.encoding, + 'cache': cache, + } + + if self.config_format: + # A format was enforced; make sure it's passed back with the url + params['format'] = self.config_format + + return 'file://{path}{params}'.format( + path=self.quote(self.path), + params='?{}'.format(self.urlencode(params)) if params else '', + ) + + def read(self, **kwargs): + """ + Perform retrieval of the configuration based on the specified request + """ + + response = None + + try: + if self.max_buffer_size > 0 and \ + os.path.getsize(self.path) > self.max_buffer_size: + + # Content exceeds maximum buffer size + self.logger.error( + 'File size exceeds maximum allowable buffer length' + ' ({}KB).'.format(int(self.max_buffer_size / 1024))) + return None + + except OSError: + # getsize() can throw this acception if the file is missing + # and or simply isn't accessible + self.logger.error( + 'File is not accessible: {}'.format(self.path)) + return None + + # Always call throttle before any server i/o is made + self.throttle() + + try: + with open(self.path, "rt", encoding=self.encoding) as f: + # Store our content for parsing + response = f.read() + + except (ValueError, UnicodeDecodeError): + # A result of our strict encoding check; if we receive this + # then the file we're opening is not something we can + # understand the encoding of.. + + self.logger.error( + 'File not using expected encoding ({}) : {}'.format( + self.encoding, self.path)) + return None + + except (IOError, OSError): + # IOError is present for backwards compatibility with Python + # versions older then 3.3. >= 3.3 throw OSError now. + + # Could not open and/or read the file; this is not a problem since + # we scan a lot of default paths. + self.logger.error( + 'File can not be opened for read: {}'.format(self.path)) + return None + + # Detect config format based on file extension if it isn't already + # enforced + if self.config_format is None and \ + re.match(r'^.*\.ya?ml\s*$', self.path, re.I) is not None: + + # YAML Filename Detected + self.default_config_format = ConfigFormat.YAML + + # Return our response object + return response + + @staticmethod + def parse_url(url): + """ + Parses the URL so that we can handle all different file paths + and return it as our path object + + """ + + results = ConfigBase.parse_url(url, verify_host=False) + if not results: + # We're done early; it's not a good URL + return results + + match = re.match(r'[a-z0-9]+://(?P[^?]+)(\?.*)?', url, re.I) + if not match: + return None + + results['path'] = ConfigFile.unquote(match.group('path')) + return results diff --git a/lib/apprise/config/ConfigHTTP.py b/lib/apprise/config/ConfigHTTP.py new file mode 100644 index 0000000..8e8677c --- /dev/null +++ b/lib/apprise/config/ConfigHTTP.py @@ -0,0 +1,275 @@ +# -*- 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 .ConfigBase import ConfigBase +from ..common import ConfigFormat +from ..common import ContentIncludeMode +from ..URLBase import PrivacyMode +from ..AppriseLocale import gettext_lazy as _ + +# Support YAML formats +# text/yaml +# text/x-yaml +# application/yaml +# application/x-yaml +MIME_IS_YAML = re.compile('(text|application)/(x-)?yaml', re.I) + +# Support TEXT formats +# text/plain +# text/html +MIME_IS_TEXT = re.compile('text/(plain|html)', re.I) + + +class ConfigHTTP(ConfigBase): + """ + A wrapper for HTTP based configuration sources + """ + + # The default descriptive name associated with the service + service_name = _('Web Based') + + # The default protocol + protocol = 'http' + + # The default secure protocol + secure_protocol = 'https' + + # If an HTTP error occurs, define the number of characters you still want + # to read back. This is useful for debugging purposes, but nothing else. + # The idea behind enforcing this kind of restriction is to prevent abuse + # from queries to services that may be untrusted. + max_error_buffer_size = 2048 + + # Configuration file inclusion can always include this type + allow_cross_includes = ContentIncludeMode.ALWAYS + + def __init__(self, headers=None, **kwargs): + """ + Initialize HTTP 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.schema = 'https' if self.secure else 'http' + + self.fullpath = kwargs.get('fullpath') + if not isinstance(self.fullpath, str): + self.fullpath = '/' + + self.headers = {} + if headers: + # Store our extra headers + self.headers.update(headers) + + return + + def url(self, privacy=False, *args, **kwargs): + """ + Returns the URL built dynamically based on specified arguments. + """ + + # Prepare our cache value + if isinstance(self.cache, bool) or not self.cache: + cache = 'yes' if self.cache else 'no' + + else: + cache = int(self.cache) + + # Define any arguments set + params = { + 'encoding': self.encoding, + 'cache': cache, + } + + # Extend our parameters + params.update(self.url_parameters(privacy=privacy, *args, **kwargs)) + + if self.config_format: + # A format was enforced; make sure it's passed back with the url + params['format'] = self.config_format + + # Append our headers into our args + params.update({'+{}'.format(k): v for k, v in self.headers.items()}) + + # Determine Authentication + auth = '' + if self.user and self.password: + auth = '{user}:{password}@'.format( + user=self.quote(self.user, safe=''), + password=self.pprint( + self.password, privacy, mode=PrivacyMode.Secret, safe=''), + ) + elif self.user: + auth = '{user}@'.format( + user=self.quote(self.user, safe=''), + ) + + default_port = 443 if self.secure else 80 + + return '{schema}://{auth}{hostname}{port}{fullpath}/?{params}'.format( + schema=self.secure_protocol if self.secure else self.protocol, + auth=auth, + hostname=self.quote(self.host, safe=''), + port='' if self.port is None or self.port == default_port + else ':{}'.format(self.port), + fullpath=self.quote(self.fullpath, safe='/'), + params=self.urlencode(params), + ) + + def read(self, **kwargs): + """ + Perform retrieval of the configuration based on the specified request + """ + + # prepare XML Object + headers = { + 'User-Agent': self.app_id, + } + + # Apply any/all header over-rides defined + headers.update(self.headers) + + auth = None + if self.user: + auth = (self.user, self.password) + + url = '%s://%s' % (self.schema, self.host) + if isinstance(self.port, int): + url += ':%d' % self.port + + url += self.fullpath + + self.logger.debug('HTTP POST URL: %s (cert_verify=%r)' % ( + url, self.verify_certificate, + )) + + # Prepare our response object + response = None + + # Where our request object will temporarily live. + r = None + + # Always call throttle before any remote server i/o is made + self.throttle() + + try: + # Make our request + with requests.post( + url, + headers=headers, + auth=auth, + verify=self.verify_certificate, + timeout=self.request_timeout, + stream=True) as r: + + # Handle Errors + r.raise_for_status() + + # Get our file-size (if known) + try: + file_size = int(r.headers.get('Content-Length', '0')) + except (TypeError, ValueError): + # Handle edge case where Content-Length is a bad value + file_size = 0 + + # Store our response + if self.max_buffer_size > 0 \ + and file_size > self.max_buffer_size: + + # Provide warning of data truncation + self.logger.error( + 'HTTP config response exceeds maximum buffer length ' + '({}KB);'.format(int(self.max_buffer_size / 1024))) + + # Return None - buffer execeeded + return None + + # Store our result (but no more than our buffer length) + response = r.text[:self.max_buffer_size + 1] + + # Verify that our content did not exceed the buffer size: + if len(response) > self.max_buffer_size: + # Provide warning of data truncation + self.logger.error( + 'HTTP config response exceeds maximum buffer length ' + '({}KB);'.format(int(self.max_buffer_size / 1024))) + + # Return None - buffer execeeded + return None + + # Detect config format based on mime if the format isn't + # already enforced + content_type = r.headers.get( + 'Content-Type', 'application/octet-stream') + if self.config_format is None and content_type: + if MIME_IS_YAML.match(content_type) is not None: + + # YAML data detected based on header content + self.default_config_format = ConfigFormat.YAML + + elif MIME_IS_TEXT.match(content_type) is not None: + + # TEXT data detected based on header content + self.default_config_format = ConfigFormat.TEXT + + except requests.RequestException as e: + self.logger.error( + 'A Connection error occurred retrieving HTTP ' + 'configuration from %s.' % self.host) + self.logger.debug('Socket Exception: %s' % str(e)) + + # Return None (signifying a failure) + return None + + # Return our response object + return response + + @staticmethod + def parse_url(url): + """ + Parses the URL and returns enough arguments that can allow + us to re-instantiate this object. + + """ + results = ConfigBase.parse_url(url) + + if not results: + # We're done early as we couldn't load the results + return results + + # Add our headers that the user can potentially over-ride if they wish + # to to our returned result set + results['headers'] = results['qsd-'] + results['headers'].update(results['qsd+']) + + return results diff --git a/lib/apprise/config/ConfigMemory.py b/lib/apprise/config/ConfigMemory.py new file mode 100644 index 0000000..110e04a --- /dev/null +++ b/lib/apprise/config/ConfigMemory.py @@ -0,0 +1,85 @@ +# -*- 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. + +from .ConfigBase import ConfigBase +from ..AppriseLocale import gettext_lazy as _ + + +class ConfigMemory(ConfigBase): + """ + For information that was loaded from memory and does not + persist anywhere. + """ + + # The default descriptive name associated with the service + service_name = _('Memory') + + # The default protocol + protocol = 'memory' + + def __init__(self, content, **kwargs): + """ + Initialize Memory Object + + Memory objects just store the raw configuration in memory. There is + no external reference point. It's always considered cached. + """ + super().__init__(**kwargs) + + # Store our raw config into memory + self.content = content + + if self.config_format is None: + # Detect our format if possible + self.config_format = \ + ConfigMemory.detect_config_format(self.content) + + return + + def url(self, privacy=False, *args, **kwargs): + """ + Returns the URL built dynamically based on specified arguments. + """ + + return 'memory://' + + def read(self, **kwargs): + """ + Simply return content stored into memory + """ + + return self.content + + @staticmethod + def parse_url(url): + """ + Memory objects have no parseable URL + + """ + # These URLs can not be parsed + return None diff --git a/lib/apprise/config/__init__.py b/lib/apprise/config/__init__.py new file mode 100644 index 0000000..4b7e3fd --- /dev/null +++ b/lib/apprise/config/__init__.py @@ -0,0 +1,109 @@ +# -*- 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 +from os import listdir +from os.path import dirname +from os.path import abspath +from ..logger import logger +from ..common import CONFIG_SCHEMA_MAP + +__all__ = [] + + +# Load our Lookup Matrix +def __load_matrix(path=abspath(dirname(__file__)), name='apprise.config'): + """ + Dynamically load our schema map; this allows us to gracefully + skip over modules we simply don't have the dependencies for. + + """ + # Used for the detection of additional Configuration Services objects + # The .py extension is optional as we support loading directories too + module_re = re.compile(r'^(?PConfig[a-z0-9]+)(\.py)?$', re.I) + + for f in listdir(path): + match = module_re.match(f) + if not match: + # keep going + continue + + # Store our notification/plugin name: + plugin_name = match.group('name') + try: + module = __import__( + '{}.{}'.format(name, plugin_name), + globals(), locals(), + fromlist=[plugin_name]) + + except ImportError: + # No problem, we can't use this object + continue + + if not hasattr(module, plugin_name): + # Not a library we can load as it doesn't follow the simple rule + # that the class must bear the same name as the notification + # file itself. + continue + + # Get our plugin + plugin = getattr(module, plugin_name) + if not hasattr(plugin, 'app_id'): + # Filter out non-notification modules + continue + + elif plugin_name in __all__: + # we're already handling this object + continue + + # Add our module name to our __all__ + __all__.append(plugin_name) + + # Ensure we provide the class as the reference to this directory and + # not the module: + globals()[plugin_name] = plugin + + fn = getattr(plugin, 'schemas', None) + schemas = set([]) if not callable(fn) else fn(plugin) + + # map our schema to our plugin + for schema in schemas: + if schema in CONFIG_SCHEMA_MAP: + logger.error( + "Config schema ({}) mismatch detected - {} to {}" + .format(schema, CONFIG_SCHEMA_MAP[schema], plugin)) + continue + + # Assign plugin + CONFIG_SCHEMA_MAP[schema] = plugin + + return CONFIG_SCHEMA_MAP + + +# Dynamically build our schema base +__load_matrix() diff --git a/lib/apprise/conversion.py b/lib/apprise/conversion.py new file mode 100644 index 0000000..d3781f6 --- /dev/null +++ b/lib/apprise/conversion.py @@ -0,0 +1,198 @@ +# -*- 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 +from markdown import markdown +from .common import NotifyFormat +from .URLBase import URLBase + +from html.parser import HTMLParser + + +def convert_between(from_format, to_format, content): + """ + Converts between different suported formats. If no conversion exists, + or the selected one fails, the original text will be returned. + + This function returns the content translated (if required) + """ + + converters = { + (NotifyFormat.MARKDOWN, NotifyFormat.HTML): markdown_to_html, + (NotifyFormat.TEXT, NotifyFormat.HTML): text_to_html, + (NotifyFormat.HTML, NotifyFormat.TEXT): html_to_text, + # For now; use same converter for Markdown support + (NotifyFormat.HTML, NotifyFormat.MARKDOWN): html_to_text, + } + + convert = converters.get((from_format, to_format)) + return convert(content) if convert else content + + +def markdown_to_html(content): + """ + Converts specified content from markdown to HTML. + """ + + return markdown(content) + + +def text_to_html(content): + """ + Converts specified content from plain text to HTML. + """ + + # First eliminate any carriage returns + return URLBase.escape_html(content, convert_new_lines=True) + + +def html_to_text(content): + """ + Converts a content from HTML to plain text. + """ + + parser = HTMLConverter() + parser.feed(content) + parser.close() + return parser.converted + + +class HTMLConverter(HTMLParser, object): + """An HTML to plain text converter tuned for email messages.""" + + # The following tags must start on a new line + BLOCK_TAGS = ('p', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', + 'div', 'td', 'th', 'code', 'pre', 'label', 'li',) + + # the folowing tags ignore any internal text + IGNORE_TAGS = ( + 'form', 'input', 'textarea', 'select', 'ul', 'ol', 'style', 'link', + 'meta', 'title', 'html', 'head', 'script') + + # Condense Whitespace + WS_TRIM = re.compile(r'[\s]+', re.DOTALL | re.MULTILINE) + + # Sentinel value for block tag boundaries, which may be consolidated into a + # single line break. + BLOCK_END = {} + + def __init__(self, **kwargs): + super().__init__(**kwargs) + + # Shoudl we store the text content or not? + self._do_store = True + + # Initialize internal result list + self._result = [] + + # Initialize public result field (not populated until close() is + # called) + self.converted = "" + + def close(self): + string = ''.join(self._finalize(self._result)) + self.converted = string.strip() + + def _finalize(self, result): + """ + Combines and strips consecutive strings, then converts consecutive + block ends into singleton newlines. + + [ {be} " Hello " {be} {be} " World!" ] -> "\nHello\nWorld!" + """ + + # None means the last visited item was a block end. + accum = None + + for item in result: + if item == self.BLOCK_END: + # Multiple consecutive block ends; do nothing. + if accum is None: + continue + + # First block end; yield the current string, plus a newline. + yield accum.strip() + '\n' + accum = None + + # Multiple consecutive strings; combine them. + elif accum is not None: + accum += item + + # First consecutive string; store it. + else: + accum = item + + # Yield the last string if we have not already done so. + if accum is not None: + yield accum.strip() + + def handle_data(self, data, *args, **kwargs): + """ + Store our data if it is not on the ignore list + """ + + # initialize our previous flag + if self._do_store: + + # Tidy our whitespace + content = self.WS_TRIM.sub(' ', data) + self._result.append(content) + + def handle_starttag(self, tag, attrs): + """ + Process our starting HTML Tag + """ + # Toggle initial states + self._do_store = tag not in self.IGNORE_TAGS + + if tag in self.BLOCK_TAGS: + self._result.append(self.BLOCK_END) + + if tag == 'li': + self._result.append('- ') + + elif tag == 'br': + self._result.append('\n') + + elif tag == 'hr': + if self._result: + self._result[-1] = self._result[-1].rstrip(' ') + + self._result.append('\n---\n') + + elif tag == 'blockquote': + self._result.append(' >') + + def handle_endtag(self, tag): + """ + Edge case handling of open/close tags + """ + self._do_store = True + + if tag in self.BLOCK_TAGS: + self._result.append(self.BLOCK_END) diff --git a/lib/apprise/decorators/CustomNotifyPlugin.py b/lib/apprise/decorators/CustomNotifyPlugin.py new file mode 100644 index 0000000..5ccfded --- /dev/null +++ b/lib/apprise/decorators/CustomNotifyPlugin.py @@ -0,0 +1,231 @@ +# -*- 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.USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +# POSSIBILITY OF SUCH DAMAGE. + +from ..plugins.NotifyBase import NotifyBase +from ..utils import URL_DETAILS_RE +from ..utils import parse_url +from ..utils import url_assembly +from ..utils import dict_full_update +from .. import common +from ..logger import logger +import inspect + + +class CustomNotifyPlugin(NotifyBase): + """ + Apprise Custom Plugin Hook + + This gets initialized based on @notify decorator definitions + + """ + # Our Custom notification + service_url = 'https://github.com/caronc/apprise/wiki/Custom_Notification' + + # Over-ride our category since this inheritance of the NotifyBase class + # should be treated differently. + category = 'custom' + + # Define object templates + templates = ( + '{schema}://', + ) + + @staticmethod + def parse_url(url): + """ + Parses the URL and returns arguments retrieved + + """ + return parse_url(url, verify_host=False, simple=True) + + def url(self, privacy=False, *args, **kwargs): + """ + General URL assembly + """ + return '{schema}://'.format(schema=self.secure_protocol) + + @staticmethod + def instantiate_plugin(url, send_func, name=None): + """ + The function used to add a new notification plugin based on the schema + parsed from the provided URL into our supported matrix structure. + """ + + if not isinstance(url, str): + msg = 'An invalid custom notify url/schema ({}) provided in ' \ + 'function {}.'.format(url, send_func.__name__) + logger.warning(msg) + return None + + # Validate that our schema is okay + re_match = URL_DETAILS_RE.match(url) + if not re_match: + msg = 'An invalid custom notify url/schema ({}) provided in ' \ + 'function {}.'.format(url, send_func.__name__) + logger.warning(msg) + return None + + # Acquire our plugin name + plugin_name = re_match.group('schema').lower() + + if not re_match.group('base'): + url = '{}://'.format(plugin_name) + + # Keep a default set of arguments to apply to all called references + base_args = parse_url( + url, default_schema=plugin_name, verify_host=False, simple=True) + + if plugin_name in common.NOTIFY_SCHEMA_MAP: + # we're already handling this object + msg = 'The schema ({}) is already defined and could not be ' \ + 'loaded from custom notify function {}.' \ + .format(url, send_func.__name__) + logger.warning(msg) + return None + + # We define our own custom wrapper class so that we can initialize + # some key default configuration values allowing calls to our + # `Apprise.details()` to correctly differentiate one custom plugin + # that was loaded from another + class CustomNotifyPluginWrapper(CustomNotifyPlugin): + + # Our Service Name + service_name = name if isinstance(name, str) \ + and name else 'Custom - {}'.format(plugin_name) + + # Store our matched schema + secure_protocol = plugin_name + + requirements = { + # Define our required packaging in order to work + 'details': "Source: {}".format(inspect.getfile(send_func)) + } + + # Assign our send() function + __send = staticmethod(send_func) + + # Update our default arguments + _base_args = base_args + + def __init__(self, **kwargs): + """ + Our initialization + + """ + # init parent + super().__init__(**kwargs) + + self._default_args = {} + + # Apply our updates based on what was parsed + dict_full_update(self._default_args, self._base_args) + dict_full_update(self._default_args, kwargs) + + # Update our arguments (applying them to what we originally) + # initialized as + self._default_args['url'] = url_assembly(**self._default_args) + + def send(self, body, title='', notify_type=common.NotifyType.INFO, + *args, **kwargs): + """ + Our send() call which triggers our hook + """ + + response = False + try: + # Enforce a boolean response + result = self.__send( + body, title, notify_type, *args, + meta=self._default_args, **kwargs) + + if result is None: + # The wrapper did not define a return (or returned + # None) + # this is treated as a successful return as it is + # assumed the developer did not care about the result + # of the call. + response = True + + else: + # Perform boolean check (allowing obects to also be + # returned and check against the __bool__ call + response = True if result else False + + except Exception as e: + # Unhandled Exception + self.logger.warning( + 'An exception occured sending a %s notification.', + common. + NOTIFY_SCHEMA_MAP[self.secure_protocol].service_name) + self.logger.debug( + '%s Exception: %s', + common.NOTIFY_SCHEMA_MAP[self.secure_protocol], str(e)) + return False + + if response: + self.logger.info( + 'Sent %s notification.', + common. + NOTIFY_SCHEMA_MAP[self.secure_protocol].service_name) + else: + self.logger.warning( + 'Failed to send %s notification.', + common. + NOTIFY_SCHEMA_MAP[self.secure_protocol].service_name) + return response + + # Store our plugin into our core map file + common.NOTIFY_SCHEMA_MAP[plugin_name] = CustomNotifyPluginWrapper + + # Update our custom plugin map + module_pyname = str(send_func.__module__) + if module_pyname not in common.NOTIFY_CUSTOM_MODULE_MAP: + # Support non-dynamic includes as well... + common.NOTIFY_CUSTOM_MODULE_MAP[module_pyname] = { + 'path': inspect.getfile(send_func), + + # Initialize our template + 'notify': {}, + } + + common.\ + NOTIFY_CUSTOM_MODULE_MAP[module_pyname]['notify'][plugin_name] = { + # Our Serivice Description (for API and CLI --details view) + 'name': CustomNotifyPluginWrapper.service_name, + # The name of the send function the @notify decorator wrapped + 'fn_name': send_func.__name__, + # The URL that was provided in the @notify decorator call + # associated with the 'on=' + 'url': url, + # The Initialized Plugin that was generated based on the above + # parameters + 'plugin': CustomNotifyPluginWrapper} + + # return our plugin + return common.NOTIFY_SCHEMA_MAP[plugin_name] diff --git a/lib/apprise/decorators/__init__.py b/lib/apprise/decorators/__init__.py new file mode 100644 index 0000000..5b089bb --- /dev/null +++ b/lib/apprise/decorators/__init__.py @@ -0,0 +1,34 @@ +# -*- 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. + +from .notify import notify + + +__all__ = [ + 'notify' +] diff --git a/lib/apprise/decorators/notify.py b/lib/apprise/decorators/notify.py new file mode 100644 index 0000000..07b4ceb --- /dev/null +++ b/lib/apprise/decorators/notify.py @@ -0,0 +1,127 @@ +# -*- 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. + +from .CustomNotifyPlugin import CustomNotifyPlugin + + +def notify(on, name=None): + """ + @notify decorator allows you to map functions you've defined to be loaded + as a regular notify by Apprise. You must identify a protocol that + users will trigger your call by. + + @notify(on="foobar") + def your_declaration(body, title, notify_type, meta, *args, **kwargs): + ... + + You can optionally provide the name to associate with the plugin which + is what calling functions via the API will receive. + + @notify(on="foobar", name="My Foobar Process") + def your_action(body, title, notify_type, meta, *args, **kwargs): + ... + + The meta variable is actually the processed URL contents found in + configuration files that landed you in this function you wrote in + the first place. It's very easily tokenized already for you so + that you can bend the notification logic to your hearts content. + + @notify(on="foobar", name="My Foobar Process") + def your_action(body, title, notify_type, body_format, meta, attach, + *args, **kwargs): + ... + + Arguments break down as follows: + body: The message body associated with the notification + title: The message title associated with the notification + notify_type: The message type (info, success, warning, and failure) + body_format: The format of the incoming notification body. This is + either text, html, or markdown. + meta: Combines the URL arguments specified on the `on` call + with the ones loaded from a users configuration. This + is a dictionary that presents itself like this: + { + 'schema': 'http', + 'url': 'http://hostname', + 'host': 'hostname', + + 'user': 'john', + 'password': 'doe', + 'port': 80, + 'path': '/', + 'fullpath': '/test.php', + 'query': 'test.php', + + 'qsd': {'key': 'value', 'key2': 'value2'}, + + 'asset': , + 'tag': set(), + } + + Meta entries are ONLY present if found. A simple URL + such as foobar:// would only produce the following: + { + 'schema': 'foobar', + 'url': 'foobar://', + + 'asset': , + 'tag': set(), + } + + attach: An array AppriseAttachment objects (if any were provided) + + body_format: Defaults to the expected format output; By default this + will be TEXT unless over-ridden in the Apprise URL + + + If you don't intend on using all of the parameters, your @notify() call + # can be greatly simplified to just: + + @notify(on="foobar", name="My Foobar Process") + def your_action(body, title, *args, **kwargs) + + Always end your wrappers declaration with *args and **kwargs to be future + proof with newer versions of Apprise. + + Your wrapper should return True if processed the send() function as you + expected and return False if not. If nothing is returned, then this is + treated as as success (True). + + """ + def wrapper(func): + """ + Instantiate our custom (notification) plugin + """ + + # Generate + CustomNotifyPlugin.instantiate_plugin( + url=on, send_func=func, name=name) + + return func + + return wrapper diff --git a/lib/apprise/i18n/__init__.py b/lib/apprise/i18n/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/lib/apprise/i18n/en/LC_MESSAGES/apprise.mo b/lib/apprise/i18n/en/LC_MESSAGES/apprise.mo new file mode 100644 index 0000000..0236722 Binary files /dev/null and b/lib/apprise/i18n/en/LC_MESSAGES/apprise.mo differ diff --git a/lib/apprise/logger.py b/lib/apprise/logger.py new file mode 100644 index 0000000..6a594ec --- /dev/null +++ b/lib/apprise/logger.py @@ -0,0 +1,201 @@ +# -*- 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 logging +from io import StringIO + +# The root identifier needed to monitor 'apprise' logging +LOGGER_NAME = 'apprise' + +# Define a verbosity level that is a noisier then debug mode +logging.TRACE = logging.DEBUG - 1 + +# Define a verbosity level that is always used even when no verbosity is set +# from the command line. The idea here is to allow for deprecation notices +logging.DEPRECATE = logging.ERROR + 1 + +# Assign our Levels into our logging object +logging.addLevelName(logging.DEPRECATE, "DEPRECATION WARNING") +logging.addLevelName(logging.TRACE, "TRACE") + + +def trace(self, message, *args, **kwargs): + """ + Verbose Debug Logging - Trace + """ + if self.isEnabledFor(logging.TRACE): + self._log(logging.TRACE, message, args, **kwargs) + + +def deprecate(self, message, *args, **kwargs): + """ + Deprication Warning Logging + """ + if self.isEnabledFor(logging.DEPRECATE): + self._log(logging.DEPRECATE, message, args, **kwargs) + + +# Assign our Loggers for use in Apprise +logging.Logger.trace = trace +logging.Logger.deprecate = deprecate + +# Create ourselve a generic (singleton) logging reference +logger = logging.getLogger(LOGGER_NAME) + + +class LogCapture: + """ + A class used to allow one to instantiate loggers that write to + memory for temporary purposes. e.g.: + + 1. with LogCapture() as captured: + 2. + 3. # Send our notification(s) + 4. aobj.notify("hello world") + 5. + 6. # retrieve our logs produced by the above call via our + 7. # `captured` StringIO object we have access to within the `with` + 8. # block here: + 9. print(captured.getvalue()) + + """ + def __init__(self, path=None, level=None, name=LOGGER_NAME, delete=True, + fmt='%(asctime)s - %(levelname)s - %(message)s'): + """ + Instantiate a temporary log capture object + + If a path is specified, then log content is sent to that file instead + of a StringIO object. + + You can optionally specify a logging level such as logging.INFO if you + wish, otherwise by default the script uses whatever logging has been + set globally. If you set delete to `False` then when using log files, + they are not automatically cleaned up afterwards. + + Optionally over-ride the fmt as well if you wish. + + """ + # Our memory buffer placeholder + self.__buffer_ptr = StringIO() + + # Store our file path as it will determine whether or not we write to + # memory and a file + self.__path = path + self.__delete = delete + + # Our logging level tracking + self.__level = level + self.__restore_level = None + + # Acquire a pointer to our logger + self.__logger = logging.getLogger(name) + + # Prepare our handler + self.__handler = logging.StreamHandler(self.__buffer_ptr) \ + if not self.__path else logging.FileHandler( + self.__path, mode='a', encoding='utf-8') + + # Use the specified level, otherwise take on the already + # effective level of our logger + self.__handler.setLevel( + self.__level if self.__level is not None + else self.__logger.getEffectiveLevel()) + + # Prepare our formatter + self.__handler.setFormatter(logging.Formatter(fmt)) + + def __enter__(self): + """ + Allows logger manipulation within a 'with' block + """ + + if self.__level is not None: + # Temporary adjust our log level if required + self.__restore_level = self.__logger.getEffectiveLevel() + if self.__restore_level > self.__level: + # Bump our log level up for the duration of our `with` + self.__logger.setLevel(self.__level) + + else: + # No restoration required + self.__restore_level = None + + else: + # Do nothing but enforce that we have nothing to restore to + self.__restore_level = None + + if self.__path: + # If a path has been identified, ensure we can write to the path + # and that the file exists + with open(self.__path, 'a'): + os.utime(self.__path, None) + + # Update our buffer pointer + self.__buffer_ptr = open(self.__path, 'r') + + # Add our handler + self.__logger.addHandler(self.__handler) + + # return our memory pointer + return self.__buffer_ptr + + def __exit__(self, exc_type, exc_value, tb): + """ + removes the handler gracefully when the with block has completed + """ + + # Flush our content + self.__handler.flush() + self.__buffer_ptr.flush() + + # Drop our handler + self.__logger.removeHandler(self.__handler) + + if self.__restore_level is not None: + # Restore level + self.__logger.setLevel(self.__restore_level) + + if self.__path: + # Close our file pointer + self.__buffer_ptr.close() + self.__handler.close() + if self.__delete: + try: + # Always remove file afterwards + os.unlink(self.__path) + + except OSError: + # It's okay if the file does not exist + pass + + if exc_type is not None: + # pass exception on if one was generated + return False + + return True diff --git a/lib/apprise/plugins/NotifyAppriseAPI.py b/lib/apprise/plugins/NotifyAppriseAPI.py new file mode 100644 index 0000000..3c85b8a --- /dev/null +++ b/lib/apprise/plugins/NotifyAppriseAPI.py @@ -0,0 +1,483 @@ +# -*- 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 +import base64 + +from .NotifyBase import NotifyBase +from ..URLBase import PrivacyMode +from ..common import NotifyType +from ..utils import parse_list +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 + """ + + # The default descriptive name associated with the Notification + service_name = 'Apprise API' + + # The services URL + service_url = 'https://github.com/caronc/apprise-api' + + # The default protocol + protocol = 'apprise' + + # The default secure protocol + secure_protocol = 'apprises' + + # 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_read_timeout = 30.0 + + # Disable throttle rate for Apprise API requests since they are normally + # local anyway + request_rate_per_sec = 0.0 + + # Define object templates + templates = ( + '{schema}://{host}/{token}', + '{schema}://{host}:{port}/{token}', + '{schema}://{user}@{host}/{token}', + '{schema}://{user}@{host}:{port}/{token}', + '{schema}://{user}:{password}@{host}/{token}', + '{schema}://{user}:{password}@{host}:{port}/{token}', + ) + + # Define our tokens; these are the minimum tokens 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, **{ + 'host': { + 'name': _('Hostname'), + 'type': 'string', + 'required': True, + }, + 'port': { + 'name': _('Port'), + 'type': 'int', + 'min': 1, + 'max': 65535, + }, + 'user': { + 'name': _('Username'), + 'type': 'string', + }, + 'password': { + 'name': _('Password'), + 'type': 'string', + 'private': True, + }, + 'token': { + 'name': _('Token'), + 'type': 'string', + 'required': True, + 'private': True, + 'regex': (r'^[A-Z0-9_-]{1,32}$', 'i'), + }, + }) + + # Define our template arguments + template_args = dict(NotifyBase.template_args, **{ + 'tags': { + 'name': _('Tags'), + 'type': 'string', + }, + 'method': { + 'name': _('Query Method'), + 'type': 'choice:string', + 'values': APPRISE_API_METHODS, + 'default': APPRISE_API_METHODS[0], + }, + 'to': { + 'alias_of': 'token', + }, + }) + + # Define any kwargs we're using + template_kwargs = { + 'headers': { + 'name': _('HTTP Header'), + 'prefix': '+', + }, + } + + def __init__(self, token=None, tags=None, method=None, headers=None, + **kwargs): + """ + Initialize Apprise API 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.token = validate_regex( + token, *self.template_tokens['token']['regex']) + if not self.token: + msg = 'The Apprise API token specified ({}) is invalid.'\ + .format(token) + 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) + + self.headers = {} + if headers: + # Store our extra headers + self.headers.update(headers) + + return + + def url(self, privacy=False, *args, **kwargs): + """ + Returns the URL built dynamically based on specified arguments. + """ + + # 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()}) + + if self.__tags: + params['tags'] = ','.join([x for x in self.__tags]) + + # Determine Authentication + auth = '' + if self.user and self.password: + auth = '{user}:{password}@'.format( + user=NotifyAppriseAPI.quote(self.user, safe=''), + password=self.pprint( + self.password, privacy, mode=PrivacyMode.Secret, safe=''), + ) + elif self.user: + auth = '{user}@'.format( + user=NotifyAppriseAPI.quote(self.user, safe=''), + ) + + default_port = 443 if self.secure else 80 + + fullpath = self.fullpath.strip('/') + return '{schema}://{auth}{hostname}{port}{fullpath}{token}' \ + '/?{params}'.format( + schema=self.secure_protocol + if self.secure else self.protocol, + 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='/{}/'.format(NotifyAppriseAPI.quote( + fullpath, safe='/')) if fullpath else '/', + token=self.pprint(self.token, privacy, safe=''), + params=NotifyAppriseAPI.urlencode(params)) + + def send(self, body, title='', notify_type=NotifyType.INFO, attach=None, + **kwargs): + """ + Perform Apprise API Notification + """ + + # 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 + 'title': title, + 'body': body, + 'type': notify_type, + '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 + + auth = None + if self.user: + auth = (self.user, self.password) + + # Set our schema + schema = 'https' if self.secure else 'http' + + url = '%s://%s' % (schema, self.host) + if isinstance(self.port, int): + url += ':%d' % self.port + + fullpath = self.fullpath.strip('/') + url += '{}'.format('/' + fullpath) if fullpath else '' + url += '/notify/{}'.format(self.token) + + # Some entries can not be over-ridden + headers.update({ + # 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 + 'X-Apprise-Recursion-Count': str(self.asset._recursion + 1), + }) + + self.logger.debug('Apprise API POST URL: %s (cert_verify=%r)' % ( + url, self.verify_certificate, + )) + self.logger.debug('Apprise API Payload: %s' % str(payload)) + + # Always call throttle before any remote server i/o is made + self.throttle() + + try: + r = requests.post( + url, + data=payload, + headers=headers, + auth=auth, + files=files if files else None, + verify=self.verify_certificate, + timeout=self.request_timeout, + ) + if r.status_code != requests.codes.ok: + # We had a problem + status_str = \ + NotifyAppriseAPI.http_response_code_lookup(r.status_code) + + self.logger.warning( + 'Failed to send Apprise API 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 Apprise API notification; method=%s.', self.method) + + except requests.RequestException as e: + self.logger.warning( + 'A Connection error occurred sending Apprise API ' + 'notification to %s.' % self.host) + self.logger.debug('Socket Exception: %s' % str(e)) + + # 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 + def parse_native_url(url): + """ + Support http://hostname/notify/token and + http://hostname/path/notify/token + """ + + result = re.match( + r'^http(?Ps?)://(?P[A-Z0-9._-]+)' + r'(:(?P[0-9]+))?' + r'(?P/[^?]+?)?/notify/(?P[A-Z0-9_-]{1,32})/?' + r'(?P\?.+)?$', url, re.I) + + if result: + return NotifyAppriseAPI.parse_url( + '{schema}://{hostname}{port}{path}/{token}/{params}'.format( + schema=NotifyAppriseAPI.secure_protocol + if result.group('secure') else NotifyAppriseAPI.protocol, + hostname=result.group('hostname'), + port='' if not result.group('port') + else ':{}'.format(result.group('port')), + path='' if not result.group('path') + else result.group('path'), + token=result.group('token'), + params='' if not result.group('params') + else '?{}'.format(result.group('params')))) + + return None + + @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) + if not results: + # We're done early as we couldn't load the results + return results + + # Add our headers that the user can potentially over-ride if they wish + # to to our returned result set and tidy entries by unquoting them + results['headers'] = \ + {NotifyAppriseAPI.unquote(x): NotifyAppriseAPI.unquote(y) + for x, y in results['qsd+'].items()} + + # Support the passing of tags in the URL + if 'tags' in results['qsd'] and len(results['qsd']['tags']): + results['tags'] = \ + NotifyAppriseAPI.parse_list(results['qsd']['tags']) + + # Support the 'to' & 'token' variable so that we can support rooms + # this way too. + if 'token' in results['qsd'] and len(results['qsd']['token']): + results['token'] = \ + NotifyAppriseAPI.unquote(results['qsd']['token']) + + elif 'to' in results['qsd'] and len(results['qsd']['to']): + results['token'] = NotifyAppriseAPI.unquote(results['qsd']['to']) + + else: + # Start with a list of path entries to work with + entries = NotifyAppriseAPI.split_path(results['fullpath']) + if entries: + # use our last entry found + results['token'] = entries[-1] + + # pop our last entry off + entries = entries[:-1] + + # 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 new file mode 100644 index 0000000..edef82b --- /dev/null +++ b/lib/apprise/plugins/NotifyBark.py @@ -0,0 +1,517 @@ +# -*- 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: https://github.com/Finb/bark-server/blob/master/docs/API_V2.md#python +# +import requests +import json + +from .NotifyBase import NotifyBase +from ..URLBase import PrivacyMode +from ..common import NotifyImageSize +from ..common import NotifyType +from ..utils import parse_list +from ..utils import parse_bool +from ..AppriseLocale import gettext_lazy as _ + + +# Sounds generated off of: https://github.com/Finb/Bark/tree/master/Sounds +BARK_SOUNDS = ( + "alarm.caf", + "anticipate.caf", + "bell.caf", + "birdsong.caf", + "bloom.caf", + "calypso.caf", + "chime.caf", + "choo.caf", + "descent.caf", + "electronic.caf", + "fanfare.caf", + "glass.caf", + "gotosleep.caf", + "healthnotification.caf", + "horn.caf", + "ladder.caf", + "mailsent.caf", + "minuet.caf", + "multiwayinvitation.caf", + "newmail.caf", + "newsflash.caf", + "noir.caf", + "paymentsuccess.caf", + "shake.caf", + "sherwoodforest.caf", + "silence.caf", + "spell.caf", + "suspense.caf", + "telegraph.caf", + "tiptoes.caf", + "typewriters.caf", + "update.caf", +) + + +# Supported Level Entries +class NotifyBarkLevel: + """ + Defines the Bark Level options + """ + ACTIVE = 'active' + + TIME_SENSITIVE = 'timeSensitive' + + PASSIVE = 'passive' + + +BARK_LEVELS = ( + NotifyBarkLevel.ACTIVE, + NotifyBarkLevel.TIME_SENSITIVE, + NotifyBarkLevel.PASSIVE, +) + + +class NotifyBark(NotifyBase): + """ + A wrapper for Notify Bark Notifications + """ + + # The default descriptive name associated with the Notification + service_name = 'Bark' + + # The services URL + service_url = 'https://github.com/Finb/Bark' + + # The default protocol + protocol = 'bark' + + # The default secure protocol + secure_protocol = 'barks' + + # A URL that takes you to the setup/help of the specific protocol + setup_url = 'https://github.com/caronc/apprise/wiki/Notify_bark' + + # Allows the user to specify the NotifyImageSize object; this is supported + # through the webhook + image_size = NotifyImageSize.XY_128 + + # Define object templates + templates = ( + '{schema}://{host}/{targets}', + '{schema}://{host}:{port}/{targets}', + '{schema}://{user}:{password}@{host}/{targets}', + '{schema}://{user}:{password}@{host}:{port}/{targets}', + ) + + # Define our template arguments + template_tokens = dict(NotifyBase.template_tokens, **{ + 'host': { + 'name': _('Hostname'), + 'type': 'string', + 'required': True, + }, + 'port': { + 'name': _('Port'), + 'type': 'int', + 'min': 1, + 'max': 65535, + }, + 'user': { + 'name': _('Username'), + 'type': 'string', + }, + 'password': { + 'name': _('Password'), + 'type': 'string', + 'private': True, + }, + 'target_device': { + 'name': _('Target Device'), + 'type': 'string', + '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', + }, + 'sound': { + 'name': _('Sound'), + 'type': 'choice:string', + 'values': BARK_SOUNDS, + }, + 'level': { + 'name': _('Level'), + 'type': 'choice:string', + 'values': BARK_LEVELS, + }, + 'click': { + 'name': _('Click'), + 'type': 'string', + }, + 'badge': { + 'name': _('Badge'), + 'type': 'int', + 'min': 0, + }, + 'category': { + 'name': _('Category'), + 'type': 'string', + }, + 'group': { + 'name': _('Group'), + 'type': 'string', + }, + 'image': { + 'name': _('Include Image'), + 'type': 'bool', + 'default': True, + 'map_to': 'include_image', + }, + }) + + def __init__(self, targets=None, include_image=True, sound=None, + category=None, group=None, level=None, click=None, + badge=None, **kwargs): + """ + Initialize Notify Bark Object + """ + super().__init__(**kwargs) + + # Prepare our URL + self.notify_url = '%s://%s%s/push' % ( + 'https' if self.secure else 'http', + self.host, + ':{}'.format(self.port) + if (self.port and isinstance(self.port, int)) else '', + ) + + # Assign our category + self.category = \ + category if isinstance(category, str) else None + + # Assign our group + self.group = group if isinstance(group, str) else None + + # Initialize device list + self.targets = parse_list(targets) + + # Place an image inline with the message body + self.include_image = include_image + + # A clickthrough option for notifications + self.click = click + + # 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 Bark badge ({}) is not valid ', badge) + + # Sound (easy-lookup) + self.sound = None if not sound else next( + (f for f in BARK_SOUNDS if f.startswith(sound.lower())), None) + if sound and not self.sound: + self.logger.warning( + 'The specified Bark sound ({}) was not found ', sound) + + # Level + self.level = None if not level else next( + (f for f in BARK_LEVELS if f[0] == level[0]), None) + if level and not self.level: + self.logger.warning( + 'The specified Bark level ({}) is not valid ', level) + + return + + def send(self, body, title='', notify_type=NotifyType.INFO, **kwargs): + """ + Perform Bark Notification + """ + + # error tracking (used for function return) + has_error = False + + if not self.targets: + # We have nothing to notify; we're done + self.logger.warning('There are no Bark devices to notify') + return False + + # Prepare our headers + headers = { + 'User-Agent': self.app_id, + 'Content-Type': 'application/json; charset=utf-8', + } + + # Prepare our payload (sample below) + # { + # "body": "Test Bark Server", + # "device_key": "nysrshcqielvoxsa", + # "title": "bleem", + # "category": "category", + # "sound": "minuet.caf", + # "badge": 1, + # "icon": "https://day.app/assets/images/avatar.jpg", + # "group": "test", + # "url": "https://mritd.com" + # } + payload = { + 'title': title if title else self.app_desc, + 'body': body, + } + + # Acquire our image url if configured to do so + image_url = None if not self.include_image else \ + self.image_url(notify_type) + + if image_url: + payload['icon'] = image_url + + if self.sound: + payload['sound'] = self.sound + + if self.click: + payload['url'] = self.click + + if self.badge: + payload['badge'] = self.badge + + if self.level: + payload['level'] = self.level + + if self.category: + payload['category'] = self.category + + if self.group: + payload['group'] = self.group + + auth = None + if self.user: + auth = (self.user, self.password) + + # Create a copy of the targets + targets = list(self.targets) + + while len(targets) > 0: + # Retrieve our device key + target = targets.pop() + + payload['device_key'] = target + self.logger.debug('Bark POST URL: %s (cert_verify=%r)' % ( + self.notify_url, self.verify_certificate, + )) + self.logger.debug('Bark Payload: %s' % str(payload)) + + # Always call throttle before any remote server i/o is made + self.throttle() + try: + r = requests.post( + self.notify_url, + data=json.dumps(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 = \ + NotifyBark.http_response_code_lookup( + r.status_code) + + self.logger.warning( + 'Failed to send Bark notification to {}: ' + '{}{}error={}.'.format( + target, + 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 Bark notification to {}.'.format(target)) + + except requests.RequestException as e: + self.logger.warning( + 'A Connection error occurred sending Bark ' + 'notification to {}.'.format(target)) + 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 = { + 'image': 'yes' if self.include_image else 'no', + } + + if self.sound: + params['sound'] = self.sound + + if self.click: + params['click'] = self.click + + if self.badge: + params['badge'] = str(self.badge) + + if self.level: + params['level'] = self.level + + if self.category: + params['category'] = self.category + + if self.group: + params['group'] = self.group + + # Extend our parameters + params.update(self.url_parameters(privacy=privacy, *args, **kwargs)) + + # Determine Authentication + auth = '' + if self.user and self.password: + auth = '{user}:{password}@'.format( + user=NotifyBark.quote(self.user, safe=''), + password=self.pprint( + self.password, privacy, mode=PrivacyMode.Secret, safe=''), + ) + elif self.user: + auth = '{user}@'.format( + user=NotifyBark.quote(self.user, safe=''), + ) + + default_port = 443 if self.secure else 80 + + return '{schema}://{auth}{hostname}{port}/{targets}?{params}'.format( + schema=self.secure_protocol if self.secure else self.protocol, + 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), + targets='/'.join( + [NotifyBark.quote('{}'.format(x)) for x in self.targets]), + 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): + """ + Parses the URL and returns enough arguments that can allow + us to re-instantiate this object. + + """ + + results = NotifyBase.parse_url(url) + if not results: + # We're done early as we couldn't load the results + return results + + # Apply our targets + results['targets'] = NotifyBark.split_path(results['fullpath']) + + # Category + if 'category' in results['qsd'] and results['qsd']['category']: + results['category'] = NotifyBark.unquote( + results['qsd']['category'].strip()) + + # Group + if 'group' in results['qsd'] and results['qsd']['group']: + results['group'] = NotifyBark.unquote( + results['qsd']['group'].strip()) + + # Badge + if 'badge' in results['qsd'] and results['qsd']['badge']: + results['badge'] = NotifyBark.unquote( + results['qsd']['badge'].strip()) + + # Level + if 'level' in results['qsd'] and results['qsd']['level']: + results['level'] = NotifyBark.unquote( + results['qsd']['level'].strip()) + + # Click (URL) + if 'click' in results['qsd'] and results['qsd']['click']: + results['click'] = NotifyBark.unquote( + results['qsd']['click'].strip()) + + # Sound + if 'sound' in results['qsd'] and results['qsd']['sound']: + results['sound'] = NotifyBark.unquote( + results['qsd']['sound'].strip()) + + # The 'to' makes it easier to use yaml configuration + if 'to' in results['qsd'] and len(results['qsd']['to']): + results['targets'] += \ + NotifyBark.parse_list(results['qsd']['to']) + + # use image= for consistency with the other plugins + results['include_image'] = \ + parse_bool(results['qsd'].get('image', True)) + + return results diff --git a/lib/apprise/plugins/NotifyBase.py b/lib/apprise/plugins/NotifyBase.py new file mode 100644 index 0000000..5138c15 --- /dev/null +++ b/lib/apprise/plugins/NotifyBase.py @@ -0,0 +1,569 @@ +# -*- 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 asyncio +import re +from functools import partial + +from ..URLBase import URLBase +from ..common import NotifyType +from ..common import NOTIFY_TYPES +from ..common import NotifyFormat +from ..common import NOTIFY_FORMATS +from ..common import OverflowMode +from ..common import OVERFLOW_MODES +from ..AppriseLocale import gettext_lazy as _ +from ..AppriseAttachment import AppriseAttachment + + +class NotifyBase(URLBase): + """ + This is the base class for all notification services + """ + + # An internal flag used to test the state of the plugin. If set to + # False, then the plugin is not used. Plugins can disable themselves + # due to enviroment issues (such as missing libraries, or platform + # dependencies that are not present). By default all plugins are + # enabled. + enabled = True + + # The category allows for parent inheritance of this object to alter + # this when it's function/use is intended to behave differently. The + # following category types exist: + # + # native: Is a native plugin written/stored in `apprise/plugins/Notify*` + # custom: Is a custom plugin written/stored in a users plugin directory + # that they loaded at execution time. + category = 'native' + + # Some plugins may require additional packages above what is provided + # already by Apprise. + # + # Use this section to relay this information to the users of the script to + # help guide them with what they need to know if they plan on using your + # plugin. The below configuration should otherwise accomodate all normal + # situations and will not requrie any updating: + requirements = { + # Use the description to provide a human interpretable description of + # what is required to make the plugin work. This is only nessisary + # if there are package dependencies. Setting this to default will + # cause a general response to be returned. Only set this if you plan + # on over-riding the default. Always consider language support here. + # So before providing a value do the following in your code base: + # + # from apprise.AppriseLocale import gettext_lazy as _ + # + # 'details': _('My detailed requirements') + 'details': None, + + # Define any required packages needed for the plugin to run. This is + # an array of strings that simply look like lines residing in a + # `requirements.txt` file... + # + # As an example, an entry may look like: + # 'packages_required': [ + # 'cryptography < 3.4`, + # ] + 'packages_required': [], + + # Recommended packages identify packages that are not required to make + # your plugin work, but would improve it's use or grant it access to + # full functionality (that might otherwise be limited). + + # Similar to `packages_required`, you would identify each entry in + # the array as you would in a `requirements.txt` file. + # + # - Do not re-provide entries already in the `packages_required` + 'packages_recommended': [], + } + + # The services URL + service_url = None + + # A URL that takes you to the setup/help of the specific protocol + setup_url = None + + # Most Servers do not like more then 1 request per 5 seconds, so 5.5 gives + # us a safe play range. Override the one defined already in the URLBase + request_rate_per_sec = 5.5 + + # Allows the user to specify the NotifyImageSize object + image_size = None + + # The maximum allowable characters allowed in the body per message + body_maxlen = 32768 + + # Defines the maximum allowable characters in the title; set this to zero + # if a title can't be used. Titles that are not used but are defined are + # automatically placed into the body + title_maxlen = 250 + + # Set the maximum line count; if this is set to anything larger then zero + # the message (prior to it being sent) will be truncated to this number + # of lines. Setting this to zero disables this feature. + body_max_line_count = 0 + + # Default Notify Format + notify_format = NotifyFormat.TEXT + + # 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 + # title so that it can be placed into the body. The default is to just + # use a tag. The below causes the title to get generated: + default_html_tag_id = 'b' + + # Here is where we define all of the arguments we accept on the url + # such as: schema://whatever/?overflow=upstream&format=text + # These act the same way as tokens except they are optional and/or + # have default values set if mandatory. This rule must be followed + template_args = dict(URLBase.template_args, **{ + 'overflow': { + 'name': _('Overflow Mode'), + 'type': 'choice:string', + 'values': OVERFLOW_MODES, + # Provide a default + 'default': overflow_mode, + # look up default using the following parent class value at + # runtime. The variable name identified here (in this case + # overflow_mode) is checked and it's result is placed over-top of + # the 'default'. This is done because once a parent class inherits + # this one, the overflow_mode already set as a default 'could' be + # potentially over-ridden and changed to a different value. + '_lookup_default': 'overflow_mode', + }, + 'format': { + 'name': _('Notify Format'), + 'type': 'choice:string', + 'values': NOTIFY_FORMATS, + # Provide a default + 'default': notify_format, + # look up default using the following parent class value at + # runtime. + '_lookup_default': 'notify_format', + }, + }) + + def __init__(self, **kwargs): + """ + Initialize some general configuration that will keep things consistent + when working with the notifiers that will inherit this class. + + """ + + super().__init__(**kwargs) + + if 'format' in kwargs: + # Store the specified format if specified + notify_format = kwargs.get('format', '') + if notify_format.lower() not in NOTIFY_FORMATS: + msg = 'Invalid notification format {}'.format(notify_format) + self.logger.error(msg) + raise TypeError(msg) + + # Provide override + self.notify_format = notify_format + + if 'overflow' in kwargs: + # Store the specified format if specified + overflow = kwargs.get('overflow', '') + if overflow.lower() not in OVERFLOW_MODES: + msg = 'Invalid overflow method {}'.format(overflow) + self.logger.error(msg) + raise TypeError(msg) + + # Provide override + self.overflow_mode = overflow + + def image_url(self, notify_type, logo=False, extension=None, + image_size=None): + """ + Returns Image URL if possible + """ + + if not self.image_size: + return None + + if notify_type not in NOTIFY_TYPES: + return None + + return self.asset.image_url( + notify_type=notify_type, + image_size=self.image_size if image_size is None else image_size, + logo=logo, + extension=extension, + ) + + def image_path(self, notify_type, extension=None): + """ + Returns the path of the image if it can + """ + if not self.image_size: + return None + + if notify_type not in NOTIFY_TYPES: + return None + + return self.asset.image_path( + notify_type=notify_type, + image_size=self.image_size, + extension=extension, + ) + + def image_raw(self, notify_type, extension=None): + """ + Returns the raw image if it can + """ + if not self.image_size: + return None + + if notify_type not in NOTIFY_TYPES: + return None + + return self.asset.image_raw( + notify_type=notify_type, + image_size=self.image_size, + extension=extension, + ) + + def color(self, notify_type, color_type=None): + """ + Returns the html color (hex code) associated with the notify_type + """ + if notify_type not in NOTIFY_TYPES: + return None + + return self.asset.color( + notify_type=notify_type, + color_type=color_type, + ) + + def notify(self, *args, **kwargs): + """ + Performs notification + """ + try: + # Build a list of dictionaries that can be used to call send(). + send_calls = list(self._build_send_calls(*args, **kwargs)) + + except TypeError: + # Internal error + return False + + else: + # Loop through each call, one at a time. (Use a list rather than a + # generator to call all the partials, even in case of a failure.) + the_calls = [self.send(**kwargs2) for kwargs2 in send_calls] + return all(the_calls) + + async def async_notify(self, *args, **kwargs): + """ + Performs notification for asynchronous callers + """ + try: + # Build a list of dictionaries that can be used to call send(). + send_calls = list(self._build_send_calls(*args, **kwargs)) + + except TypeError: + # Internal error + return False + + else: + loop = asyncio.get_event_loop() + + # Wrap each call in a coroutine that uses the default executor. + # TODO: In the future, allow plugins to supply a native + # async_send() method. + async def do_send(**kwargs2): + send = partial(self.send, **kwargs2) + result = await loop.run_in_executor(None, send) + return result + + # gather() all calls in parallel. + the_cors = (do_send(**kwargs2) for kwargs2 in send_calls) + return all(await asyncio.gather(*the_cors)) + + def _build_send_calls(self, body=None, title=None, + notify_type=NotifyType.INFO, overflow=None, + attach=None, body_format=None, **kwargs): + """ + Get a list of dictionaries that can be used to call send() or + (in the future) async_send(). + """ + + if not self.enabled: + # Deny notifications issued to services that are disabled + msg = f"{self.service_name} is currently disabled on this system." + self.logger.warning(msg) + raise TypeError(msg) + + # Prepare attachments if required + if attach is not None and not isinstance(attach, AppriseAttachment): + try: + attach = AppriseAttachment(attach, asset=self.asset) + + except TypeError: + # 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 + + # Apply our overflow (if defined) + for chunk in self._apply_overflow( + body=body, title=title, overflow=overflow, + body_format=body_format): + + # Send notification + yield dict( + body=chunk['body'], title=chunk['title'], + notify_type=notify_type, attach=attach, + body_format=body_format + ) + + def _apply_overflow(self, body, title=None, overflow=None, + body_format=None): + """ + Takes the message body and title as input. This function then + applies any defined overflow restrictions associated with the + notification service and may alter the message if/as required. + + The function will always return a list object in the following + structure: + [ + { + title: 'the title goes here', + body: 'the message body goes here', + }, + { + title: 'the title goes here', + body: 'the message body goes here', + }, + + ] + """ + + response = list() + + # tidy + title = '' if not title else title.strip() + body = '' if not body else body.rstrip() + + if overflow is None: + # default + overflow = self.overflow_mode + + if self.title_maxlen <= 0 and len(title) > 0: + + if self.notify_format == NotifyFormat.HTML: + # Content is appended to body as html + body = '<{open_tag}>{title}' \ + '
\r\n{body}'.format( + open_tag=self.default_html_tag_id, + title=title, + close_tag=self.default_html_tag_id, + body=body) + + elif self.notify_format == NotifyFormat.MARKDOWN and \ + body_format == NotifyFormat.TEXT: + # Content is appended to body as markdown + title = title.lstrip('\r\n \t\v\f#-') + if title: + # Content is appended to body as text + body = '# {}\r\n{}'.format(title, body) + + else: + # Content is appended to body as text + body = '{}\r\n{}'.format(title, body) + + title = '' + + # Enforce the line count first always + if self.body_max_line_count > 0: + # Limit results to just the first 2 line otherwise + # there is just to much content to display + body = re.split(r'\r*\n', body) + body = '\r\n'.join(body[0:self.body_max_line_count]) + + if overflow == OverflowMode.UPSTREAM: + # Nothing more to do + response.append({'body': body, 'title': title}) + return response + + elif len(title) > self.title_maxlen: + # Truncate our Title + title = title[:self.title_maxlen] + + if self.body_maxlen > 0 and len(body) <= self.body_maxlen: + response.append({'body': body, 'title': title}) + return response + + if overflow == OverflowMode.TRUNCATE: + # Truncate our body and return + response.append({ + 'body': body[:self.body_maxlen], + 'title': title, + }) + # For truncate mode, we're done now + return response + + # If we reach here, then we are in SPLIT mode. + # For here, we want to split the message as many times as we have to + # in order to fit it within the designated limits. + response = [{ + 'body': body[i: i + self.body_maxlen], + 'title': title} for i in range(0, len(body), self.body_maxlen)] + + return response + + def send(self, body, title='', notify_type=NotifyType.INFO, **kwargs): + """ + Should preform the actual notification itself. + + """ + raise NotImplementedError( + "send() is not implimented by the child class.") + + def url_parameters(self, *args, **kwargs): + """ + Provides a default set of parameters to work with. This can greatly + simplify URL construction in the acommpanied url() function in all + defined plugin services. + """ + + params = { + 'format': self.notify_format, + 'overflow': self.overflow_mode, + } + + params.update(super().url_parameters(*args, **kwargs)) + + # return default parameters + return params + + @staticmethod + def parse_url(url, verify_host=True, plus_to_space=False): + """Parses the URL and returns it broken apart into a dictionary. + + This is very specific and customized for Apprise. + + + Args: + url (str): The URL you want to fully parse. + verify_host (:obj:`bool`, optional): a flag kept with the parsed + URL which some child classes will later use to verify SSL + keys (if SSL transactions take place). Unless under very + specific circumstances, it is strongly recomended that + you leave this default value set to True. + + Returns: + A dictionary is returned containing the URL fully parsed if + successful, otherwise None is returned. + """ + results = URLBase.parse_url( + url, verify_host=verify_host, plus_to_space=plus_to_space) + + if not results: + # We're done; we failed to parse our url + return results + + # Allow overriding the default format + if 'format' in results['qsd']: + results['format'] = results['qsd'].get('format') + if results['format'] not in NOTIFY_FORMATS: + URLBase.logger.warning( + 'Unsupported format specified {}'.format( + results['format'])) + del results['format'] + + # Allow overriding the default overflow + if 'overflow' in results['qsd']: + results['overflow'] = results['qsd'].get('overflow') + if results['overflow'] not in OVERFLOW_MODES: + URLBase.logger.warning( + 'Unsupported overflow specified {}'.format( + results['overflow'])) + del results['overflow'] + + return results + + @staticmethod + def parse_native_url(url): + """ + This is a base class that can be optionally over-ridden by child + classes who can build their Apprise URL based on the one provided + by the notification service they choose to use. + + The intent of this is to make Apprise a little more userfriendly + to people who aren't familiar with constructing URLs and wish to + use the ones that were just provied by their notification serivice + that they're using. + + This function will return None if the passed in URL can't be matched + as belonging to the notification service. Otherwise this function + should return the same set of results that parse_url() does. + """ + return None diff --git a/lib/apprise/plugins/NotifyBase.pyi b/lib/apprise/plugins/NotifyBase.pyi new file mode 100644 index 0000000..9cf3e40 --- /dev/null +++ b/lib/apprise/plugins/NotifyBase.pyi @@ -0,0 +1 @@ +class NotifyBase: ... \ No newline at end of file diff --git a/lib/apprise/plugins/NotifyBoxcar.py b/lib/apprise/plugins/NotifyBoxcar.py new file mode 100644 index 0000000..9d3be6a --- /dev/null +++ b/lib/apprise/plugins/NotifyBoxcar.py @@ -0,0 +1,399 @@ +# -*- 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 +import hmac +from json import dumps +from time import time +from hashlib import sha1 +from itertools import chain +try: + from urlparse import urlparse + +except ImportError: + from urllib.parse import urlparse + +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 +from ..AppriseLocale import gettext_lazy as _ + +# Default to sending to all devices if nothing is specified +DEFAULT_TAG = '@all' + +# The tags value is an structure containing an array of strings defining the +# 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) + +# Device tokens are only referenced when developing. +# It's not likely you'll send a message directly to a device, but if you do; +# this plugin supports it. +IS_DEVICETOKEN = re.compile(r'^[A-Z0-9]{64}$', re.I) + +# Used to break apart list of potential tags by their delimiter into a useable +# list. +TAGS_LIST_DELIM = re.compile(r'[ \t\r\n,\\/]+') + + +class NotifyBoxcar(NotifyBase): + """ + A wrapper for Boxcar Notifications + """ + + # The default descriptive name associated with the Notification + service_name = 'Boxcar' + + # The services URL + service_url = 'https://boxcar.io/' + + # All boxcar notifications are secure + secure_protocol = 'boxcar' + + # A URL that takes you to the setup/help of the specific protocol + setup_url = 'https://github.com/caronc/apprise/wiki/Notify_boxcar' + + # Boxcar URL + notify_url = 'https://boxcar-api.io/api/push/' + + # Allows the user to specify the NotifyImageSize object + image_size = NotifyImageSize.XY_72 + + # The maximum allowable characters allowed in the body per message + body_maxlen = 10000 + + # Define object templates + templates = ( + '{schema}://{access_key}/{secret_key}/', + '{schema}://{access_key}/{secret_key}/{targets}', + ) + + # Define our template tokens + template_tokens = dict(NotifyBase.template_tokens, **{ + 'access_key': { + 'name': _('Access Key'), + 'type': 'string', + 'private': True, + 'required': True, + 'regex': (r'^[A-Z0-9_-]{64}$', 'i'), + 'map_to': 'access', + }, + 'secret_key': { + 'name': _('Secret Key'), + 'type': 'string', + 'private': True, + 'required': True, + 'regex': (r'^[A-Z0-9_-]{64}$', 'i'), + 'map_to': 'secret', + }, + 'target_tag': { + 'name': _('Target Tag ID'), + 'type': 'string', + 'prefix': '@', + 'regex': (r'^[A-Z0-9]{1,63}$', 'i'), + 'map_to': 'targets', + }, + 'target_device': { + 'name': _('Target Device ID'), + 'type': 'string', + 'regex': (r'^[A-Z0-9]{64}$', 'i'), + 'map_to': 'targets', + }, + 'targets': { + 'name': _('Targets'), + 'type': 'list:string', + }, + }) + + # Define our template arguments + template_args = dict(NotifyBase.template_args, **{ + 'image': { + 'name': _('Include Image'), + 'type': 'bool', + 'default': True, + 'map_to': 'include_image', + }, + 'to': { + 'alias_of': 'targets', + }, + 'access': { + 'alias_of': 'access_key', + }, + 'secret': { + 'alias_of': 'secret_key', + }, + }) + + def __init__(self, access, secret, targets=None, include_image=True, + **kwargs): + """ + Initialize Boxcar Object + """ + super().__init__(**kwargs) + + # Initialize tag list + self._tags = list() + + # Initialize device_token list + self.device_tokens = list() + + # Access Key (associated with project) + self.access = validate_regex( + access, *self.template_tokens['access_key']['regex']) + if not self.access: + msg = 'An invalid Boxcar Access Key ' \ + '({}) was specified.'.format(access) + self.logger.warning(msg) + raise TypeError(msg) + + # Secret Key (associated with project) + self.secret = validate_regex( + secret, *self.template_tokens['secret_key']['regex']) + if not self.secret: + msg = 'An invalid Boxcar Secret Key ' \ + '({}) was specified.'.format(secret) + self.logger.warning(msg) + raise TypeError(msg) + + if not targets: + self._tags.append(DEFAULT_TAG) + targets = [] + + # Validate targets and drop bad ones: + for target in parse_list(targets): + result = IS_TAG.match(target) + if result: + # store valid tag/alias + self._tags.append(result.group('name')) + continue + + result = IS_DEVICETOKEN.match(target) + if result: + # store valid device + self.device_tokens.append(target) + continue + + 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. + self.include_image = include_image + + return + + def send(self, body, title='', notify_type=NotifyType.INFO, **kwargs): + """ + Perform Boxcar Notification + """ + headers = { + 'User-Agent': self.app_id, + 'Content-Type': 'application/json' + } + + # prepare Boxcar Object + payload = { + 'aps': { + 'badge': 'auto', + 'alert': '', + }, + 'expires': str(int(time() + 30)), + } + + if title: + payload['aps']['@title'] = title + + payload['aps']['alert'] = body + + if self._tags: + payload['tags'] = {'or': self._tags} + + if self.device_tokens: + payload['device_tokens'] = self.device_tokens + + # Source picture should be <= 450 DP wide, ~2:1 aspect. + image_url = None if not self.include_image \ + else self.image_url(notify_type) + + if image_url: + # Set our image + payload['@img'] = image_url + + # Acquire our hostname + host = urlparse(self.notify_url).hostname + + # Calculate signature. + str_to_sign = "%s\n%s\n%s\n%s" % ( + "POST", host, "/api/push", dumps(payload)) + + h = hmac.new( + bytearray(self.secret, 'utf-8'), + bytearray(str_to_sign, 'utf-8'), + sha1, + ) + + params = NotifyBoxcar.urlencode({ + "publishkey": self.access, + "signature": h.hexdigest(), + }) + + notify_url = '%s?%s' % (self.notify_url, params) + self.logger.debug('Boxcar POST URL: %s (cert_verify=%r)' % ( + notify_url, self.verify_certificate, + )) + self.logger.debug('Boxcar 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, + ) + + # Boxcar returns 201 (Created) when successful + if r.status_code != requests.codes.created: + # We had a problem + status_str = \ + NotifyBoxcar.http_response_code_lookup(r.status_code) + + self.logger.warning( + 'Failed to send Boxcar 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 Boxcar notification.') + + except requests.RequestException as e: + self.logger.warning( + 'A Connection error occurred sending Boxcar ' + 'notification to %s.' % (host)) + + self.logger.debug('Socket Exception: %s' % str(e)) + + # Return; we're done + return False + + return True + + def url(self, privacy=False, *args, **kwargs): + """ + Returns the URL built dynamically based on specified arguments. + """ + + # Define any URL parameters + params = { + 'image': 'yes' if self.include_image else 'no', + } + + # Extend our parameters + params.update(self.url_parameters(privacy=privacy, *args, **kwargs)) + + return '{schema}://{access}/{secret}/{targets}?{params}'.format( + schema=self.secure_protocol, + access=self.pprint(self.access, privacy, safe=''), + secret=self.pprint( + 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]), + 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): + """ + Parses the URL and returns it broken apart into a dictionary. + + """ + results = NotifyBase.parse_url(url, verify_host=False) + if not results: + # We're done early + return None + + # The first token is stored in the hostname + results['access'] = NotifyBoxcar.unquote(results['host']) + + # Get our entries; split_path() looks after unquoting content for us + # by default + entries = NotifyBoxcar.split_path(results['fullpath']) + + # Now fetch the remaining tokens + results['secret'] = entries.pop(0) if entries else None + + # Our recipients make up the remaining entries of our array + results['targets'] = entries + + # The 'to' makes it easier to use yaml configuration + if 'to' in results['qsd'] and len(results['qsd']['to']): + 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)) + + return results diff --git a/lib/apprise/plugins/NotifyBulkSMS.py b/lib/apprise/plugins/NotifyBulkSMS.py new file mode 100644 index 0000000..cf82a87 --- /dev/null +++ b/lib/apprise/plugins/NotifyBulkSMS.py @@ -0,0 +1,480 @@ +# -*- 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. + +# To use this service you will need a BulkSMS account +# You will need credits (new accounts start with a few) +# https://www.bulksms.com/account/ +# +# API is documented here: +# - https://www.bulksms.com/developer/json/v1/#tag/Message +import re +import requests +import json +from itertools import chain +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 ..AppriseLocale import gettext_lazy as _ + + +IS_GROUP_RE = re.compile( + r'^(@?(?P[A-Z0-9_-]+))$', + re.IGNORECASE, +) + + +class BulkSMSRoutingGroup(object): + """ + The different categories of routing + """ + ECONOMY = "ECONOMY" + STANDARD = "STANDARD" + PREMIUM = "PREMIUM" + + +# Used for verification purposes +BULKSMS_ROUTING_GROUPS = ( + BulkSMSRoutingGroup.ECONOMY, + BulkSMSRoutingGroup.STANDARD, + BulkSMSRoutingGroup.PREMIUM, +) + + +class BulkSMSEncoding(object): + """ + The different categories of routing + """ + TEXT = "TEXT" + UNICODE = "UNICODE" + BINARY = "BINARY" + + +class NotifyBulkSMS(NotifyBase): + """ + A wrapper for BulkSMS Notifications + """ + + # The default descriptive name associated with the Notification + service_name = 'BulkSMS' + + # The services URL + service_url = 'https://bulksms.com/' + + # All notification requests are secure + secure_protocol = 'bulksms' + + # A URL that takes you to the setup/help of the specific protocol + setup_url = 'https://github.com/caronc/apprise/wiki/Notify_bulksms' + + # BulkSMS uses the http protocol with JSON requests + notify_url = 'https://api.bulksms.com/v1/messages' + + # The maximum length of the body + body_maxlen = 160 + + # The maximum amount of texts that can go out in one batch + default_batch_size = 4000 + + # 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}://{user}:{password}@{targets}', + ) + + # Define our template tokens + template_tokens = dict(NotifyBase.template_tokens, **{ + 'user': { + 'name': _('User Name'), + 'type': 'string', + 'required': True, + }, + 'password': { + 'name': _('Password'), + 'type': 'string', + 'private': True, + 'required': True, + }, + 'target_phone': { + 'name': _('Target Phone No'), + 'type': 'string', + 'prefix': '+', + 'regex': (r'^[0-9\s)(+-]+$', 'i'), + 'map_to': 'targets', + }, + 'target_group': { + 'name': _('Target Group'), + 'type': 'string', + 'prefix': '+', + 'regex': (r'^[A-Z0-9 _-]+$', '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': { + 'name': _('From Phone No'), + 'type': 'string', + 'regex': (r'^\+?[0-9\s)(+-]+$', 'i'), + 'map_to': 'source', + }, + 'route': { + 'name': _('Route Group'), + 'type': 'choice:string', + 'values': BULKSMS_ROUTING_GROUPS, + 'default': BulkSMSRoutingGroup.STANDARD, + }, + 'unicode': { + # Unicode characters + 'name': _('Unicode Characters'), + 'type': 'bool', + 'default': True, + }, + 'batch': { + 'name': _('Batch Mode'), + 'type': 'bool', + 'default': False, + }, + }) + + def __init__(self, source=None, targets=None, unicode=None, batch=None, + route=None, **kwargs): + """ + Initialize BulkSMS Object + """ + super(NotifyBulkSMS, self).__init__(**kwargs) + + self.source = None + if source: + result = is_phone_no(source) + if not result: + msg = 'The Account (From) Phone # specified ' \ + '({}) is invalid.'.format(source) + self.logger.warning(msg) + raise TypeError(msg) + + # Tidy source + self.source = '+{}'.format(result['full']) + + # Setup our route + self.route = self.template_args['route']['default'] \ + if not isinstance(route, str) else route.upper() + if self.route not in BULKSMS_ROUTING_GROUPS: + msg = 'The route specified ({}) is invalid.'.format(route) + self.logger.warning(msg) + raise TypeError(msg) + + # Define whether or not we should set the unicode flag + self.unicode = self.template_args['unicode']['default'] \ + if unicode is None else bool(unicode) + + # Define whether or not we should operate in a batch mode + self.batch = self.template_args['batch']['default'] \ + if batch is None else bool(batch) + + # Parse our targets + self.targets = list() + self.groups = list() + + for target in parse_phone_no(targets): + # Parse each phone number we found + result = is_phone_no(target) + if result: + self.targets.append('+{}'.format(result['full'])) + continue + + group_re = IS_GROUP_RE.match(target) + if group_re and not target.isdigit(): + # If the target specified is all digits, it MUST have a @ + # in front of it to eliminate any ambiguity + self.groups.append(group_re.group('group')) + continue + + self.logger.warning( + 'Dropped invalid phone # and/or Group ' + '({}) specified.'.format(target), + ) + + return + + def send(self, body, title='', notify_type=NotifyType.INFO, **kwargs): + """ + Perform BulkSMS Notification + """ + + if not (self.password and self.user): + self.logger.warning( + 'There were no valid login credentials provided') + return False + + if not (self.targets or self.groups): + # We have nothing to notify + self.logger.warning('There are no Twist targets to notify') + return False + + # Send in batches if identified to do so + batch_size = 1 if not self.batch else self.default_batch_size + + # error tracking (used for function return) + has_error = False + + # Prepare our headers + headers = { + 'User-Agent': self.app_id, + 'Content-Type': 'application/json', + } + + # Prepare our payload + payload = { + # The To gets populated in the loop below + 'to': None, + 'body': body, + 'routingGroup': self.route, + 'encoding': BulkSMSEncoding.UNICODE \ + if self.unicode else BulkSMSEncoding.TEXT, + # Options are NONE, ALL and ERRORS + 'deliveryReports': "ERRORS" + } + + if self.source: + payload.update({ + 'from': self.source, + }) + + # Authentication + auth = (self.user, self.password) + + # Prepare our targets + targets = list(self.targets) if batch_size == 1 else \ + [self.targets[index:index + batch_size] + for index in range(0, len(self.targets), batch_size)] + targets += [{"type": "GROUP", "name": g} for g in self.groups] + + while len(targets): + # Get our target to notify + target = targets.pop(0) + + # Prepare our user + payload['to'] = target + + # Printable reference + if isinstance(target, dict): + p_target = target['name'] + + elif isinstance(target, list): + p_target = '{} targets'.format(len(target)) + + else: + p_target = target + + # Some Debug Logging + self.logger.debug('BulkSMS POST URL: {} (cert_verify={})'.format( + self.notify_url, self.verify_certificate)) + self.logger.debug('BulkSMS Payload: {}' .format(payload)) + + # Always call throttle before any remote server i/o is made + self.throttle() + try: + r = requests.post( + self.notify_url, + data=json.dumps(payload), + headers=headers, + auth=auth, + verify=self.verify_certificate, + timeout=self.request_timeout, + ) + + # The responsne might look like: + # [ + # { + # "id": "string", + # "type": "SENT", + # "from": "string", + # "to": "string", + # "body": null, + # "encoding": "TEXT", + # "protocolId": 0, + # "messageClass": 0, + # "numberOfParts": 0, + # "creditCost": 0, + # "submission": {...}, + # "status": {...}, + # "relatedSentMessageId": "string", + # "userSuppliedId": "string" + # } + # ] + + 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 + + self.logger.warning( + 'Failed to send BulkSMS notification to {}: ' + '{}{}error={}.'.format( + p_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 BulkSMS notification to {}.'.format(p_target)) + + except requests.RequestException as e: + self.logger.warning( + 'A Connection error occurred sending BulkSMS: to %s ', + p_target) + 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 = { + 'unicode': 'yes' if self.unicode else 'no', + 'batch': 'yes' if self.batch else 'no', + 'route': self.route, + } + + if self.source: + params['from'] = self.source + + # Extend our parameters + params.update(self.url_parameters(privacy=privacy, *args, **kwargs)) + + return '{schema}://{user}:{password}@{targets}/?{params}'.format( + schema=self.secure_protocol, + user=self.pprint(self.user, privacy, safe=''), + password=self.pprint( + self.password, privacy, mode=PrivacyMode.Secret, safe=''), + targets='/'.join(chain( + [NotifyBulkSMS.quote('{}'.format(x), safe='+') + for x in self.targets], + [NotifyBulkSMS.quote('@{}'.format(x), safe='@') + 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): + """ + 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'] = [ + NotifyBulkSMS.unquote(results['host']), + *NotifyBulkSMS.split_path(results['fullpath'])] + + # 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'] = \ + NotifyBulkSMS.unquote(results['qsd']['from']) + + # 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'] += \ + NotifyBulkSMS.parse_phone_no(results['qsd']['to']) + + # Unicode Characters + results['unicode'] = \ + parse_bool(results['qsd'].get( + 'unicode', NotifyBulkSMS.template_args['unicode']['default'])) + + # Get Batch Mode Flag + results['batch'] = \ + parse_bool(results['qsd'].get( + 'batch', NotifyBulkSMS.template_args['batch']['default'])) + + # Allow one to define a route group + if 'route' in results['qsd'] and len(results['qsd']['route']): + results['route'] = \ + NotifyBulkSMS.unquote(results['qsd']['route']) + + return results diff --git a/lib/apprise/plugins/NotifyBurstSMS.py b/lib/apprise/plugins/NotifyBurstSMS.py new file mode 100644 index 0000000..59219b3 --- /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 new file mode 100644 index 0000000..670e74e --- /dev/null +++ b/lib/apprise/plugins/NotifyClickSend.py @@ -0,0 +1,331 @@ +# -*- 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. + +# To use this plugin, simply signup with clicksend: +# https://www.clicksend.com/ +# +# You're done at this point, you only need to know your user/pass that +# you signed up with. + +# The following URLs would be accepted by Apprise: +# - clicksend://{user}:{password}@{phoneno} +# - clicksend://{user}:{password}@{phoneno1}/{phoneno2} + +# The API reference used to build this plugin was documented here: +# https://developers.clicksend.com/docs/rest/v3/ +# +import requests +from json import dumps +from base64 import b64encode + +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 ..AppriseLocale import gettext_lazy as _ + +# Extend HTTP Error Messages +CLICKSEND_HTTP_ERROR_MAP = { + 401: 'Unauthorized - Invalid Token.', +} + + +class NotifyClickSend(NotifyBase): + """ + A wrapper for ClickSend Notifications + """ + + # The default descriptive name associated with the Notification + service_name = 'ClickSend' + + # The services URL + service_url = 'https://clicksend.com/' + + # The default secure protocol + secure_protocol = 'clicksend' + + # A URL that takes you to the setup/help of the specific protocol + setup_url = 'https://github.com/caronc/apprise/wiki/Notify_clicksend' + + # ClickSend uses the http protocol with JSON requests + notify_url = 'https://rest.clicksend.com/v3/sms/send' + + # 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 + + # The maximum SMS batch size accepted by the ClickSend API + default_batch_size = 1000 + + # Define object templates + templates = ( + '{schema}://{user}:{password}@{targets}', + ) + + # Define our template tokens + template_tokens = dict(NotifyBase.template_tokens, **{ + 'user': { + 'name': _('User Name'), + 'type': 'string', + 'required': True, + }, + 'password': { + 'name': _('Password'), + 'type': 'string', + 'private': True, + 'required': True, + }, + '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', + }, + 'batch': { + 'name': _('Batch Mode'), + 'type': 'bool', + 'default': False, + }, + }) + + def __init__(self, targets=None, batch=False, **kwargs): + """ + Initialize ClickSend Object + """ + super().__init__(**kwargs) + + # Prepare Batch Mode Flag + self.batch = batch + + # Parse our targets + self.targets = list() + + if not (self.user and self.password): + msg = 'A ClickSend user/pass was not provided.' + self.logger.warning(msg) + raise TypeError(msg) + + 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']) + + def send(self, body, title='', notify_type=NotifyType.INFO, **kwargs): + """ + Perform ClickSend Notification + """ + + if len(self.targets) == 0: + # There were no services to notify + self.logger.warning('There were no ClickSend targets to notify.') + return False + + headers = { + 'User-Agent': self.app_id, + 'Content-Type': 'application/json; charset=utf-8', + 'Authorization': 'Basic {}'.format( + b64encode('{}:{}'.format( + self.user, self.password).encode('utf-8'))), + } + + # error tracking (used for function return) + has_error = False + + # prepare JSON Object + payload = { + 'messages': [] + } + + # Send in batches if identified to do so + default_batch_size = 1 if not self.batch else self.default_batch_size + + for index in range(0, len(self.targets), default_batch_size): + payload['messages'] = [{ + 'source': 'php', + 'body': body, + 'to': '+{}'.format(to), + } for to in self.targets[index:index + default_batch_size]] + + self.logger.debug('ClickSend POST URL: %s (cert_verify=%r)' % ( + self.notify_url, self.verify_certificate, + )) + self.logger.debug('ClickSend Payload: %s' % str(payload)) + + # 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 != requests.codes.ok: + # We had a problem + status_str = \ + NotifyClickSend.http_response_code_lookup( + r.status_code, CLICKSEND_HTTP_ERROR_MAP) + + self.logger.warning( + 'Failed to send {} ClickSend notification{}: ' + '{}{}error={}.'.format( + len(payload['messages']), + ' to {}'.format(self.targets[index]) + if default_batch_size == 1 else '(s)', + 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 {} ClickSend notification{}.' + .format( + len(payload['messages']), + ' to {}'.format(self.targets[index]) + if default_batch_size == 1 else '(s)', + )) + + except requests.RequestException as e: + self.logger.warning( + 'A Connection error occurred sending {} ClickSend ' + 'notification(s).'.format(len(payload['messages']))) + 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 = { + 'batch': 'yes' if self.batch else 'no', + } + + # Extend our parameters + params.update(self.url_parameters(privacy=privacy, *args, **kwargs)) + + # Setup Authentication + auth = '{user}:{password}@'.format( + user=NotifyClickSend.quote(self.user, safe=''), + password=self.pprint( + self.password, privacy, mode=PrivacyMode.Secret, safe=''), + ) + + return '{schema}://{auth}{targets}?{params}'.format( + schema=self.secure_protocol, + auth=auth, + targets='/'.join( + [NotifyClickSend.quote(x, safe='') for x in self.targets]), + 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): + """ + 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 + + # All elements are targets + results['targets'] = [NotifyClickSend.unquote(results['host'])] + + # All entries after the hostname are additional targets + results['targets'].extend( + NotifyClickSend.split_path(results['fullpath'])) + + # Get Batch Mode Flag + results['batch'] = \ + parse_bool(results['qsd'].get('batch', False)) + + # 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'] += \ + NotifyClickSend.parse_phone_no(results['qsd']['to']) + + return results diff --git a/lib/apprise/plugins/NotifyD7Networks.py b/lib/apprise/plugins/NotifyD7Networks.py new file mode 100644 index 0000000..3e7787d --- /dev/null +++ b/lib/apprise/plugins/NotifyD7Networks.py @@ -0,0 +1,429 @@ +# -*- 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. + +# To use this service you will need a D7 Networks account from their website +# at https://d7networks.com/ +# +# After you've established your account you can get your api login credentials +# (both user and password) from the API Details section from within your +# account profile area: https://d7networks.com/accounts/profile/ +# +# API Reference: https://d7networks.com/docs/Messages/Send_Message/ + +import requests +from json import dumps +from json import loads + +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 ..utils import parse_bool +from ..AppriseLocale import gettext_lazy as _ + +# Extend HTTP Error Messages +D7NETWORKS_HTTP_ERROR_MAP = { + 401: 'Invalid Argument(s) Specified.', + 403: 'Unauthorized - Authentication Failure.', + 412: 'A Routing Error Occured', + 500: 'A Serverside Error Occured Handling the Request.', +} + + +class NotifyD7Networks(NotifyBase): + """ + A wrapper for D7 Networks Notifications + """ + + # The default descriptive name associated with the Notification + service_name = 'D7 Networks' + + # The services URL + service_url = 'https://d7networks.com/' + + # All notification requests are secure + secure_protocol = 'd7sms' + + # Allow 300 requests per minute. + # 60/300 = 0.2 + request_rate_per_sec = 0.20 + + # A URL that takes you to the setup/help of the specific protocol + setup_url = 'https://github.com/caronc/apprise/wiki/Notify_d7networks' + + # D7 Networks single notification URL + notify_url = 'https://api.d7networks.com/messages/v1/send' + + # 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}://{token}@{targets}', + ) + + # Define our template tokens + template_tokens = dict(NotifyBase.template_tokens, **{ + 'token': { + 'name': _('API Access Token'), + 'type': 'string', + 'required': True, + 'private': True, + }, + '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, **{ + 'unicode': { + # Unicode characters (default is 'auto') + 'name': _('Unicode Characters'), + 'type': 'bool', + 'default': False, + }, + 'batch': { + 'name': _('Batch Mode'), + 'type': 'bool', + 'default': False, + }, + 'to': { + 'alias_of': 'targets', + }, + 'source': { + # Originating address,In cases where the rewriting of the sender's + # address is supported or permitted by the SMS-C. This is used to + # transmit the message, this number is transmitted as the + # originating address and is completely optional. + 'name': _('Originating Address'), + 'type': 'string', + 'map_to': 'source', + + }, + 'from': { + 'alias_of': 'source', + }, + }) + + def __init__(self, token=None, targets=None, source=None, + batch=False, unicode=None, **kwargs): + """ + Initialize D7 Networks Object + """ + super().__init__(**kwargs) + + # Prepare Batch Mode Flag + self.batch = batch + + # Setup our source address (if defined) + self.source = None \ + if not isinstance(source, str) else source.strip() + + # Define whether or not we should set the unicode flag + self.unicode = self.template_args['unicode']['default'] \ + if unicode is None else bool(unicode) + + # The token associated with the account + self.token = validate_regex(token) + if not self.token: + msg = 'The D7 Networks token specified ({}) is invalid.'\ + .format(token) + 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 = 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): + """ + Depending on whether we are set to batch mode or single mode this + redirects to the appropriate handling + """ + + if len(self.targets) == 0: + # There were no services to notify + self.logger.warning('There were no D7 Networks targets to notify.') + return False + + # error tracking (used for function return) + has_error = False + + # Prepare our headers + headers = { + 'User-Agent': self.app_id, + 'Content-Type': 'application/json', + 'Accept': 'application/json', + 'Authorization': f'Bearer {self.token}', + } + + payload = { + 'message_globals': { + 'channel': 'sms', + }, + 'messages': [{ + # Populated later on + 'recipients': None, + 'content': body, + 'data_coding': + # auto is a better substitute over 'text' as text is easier to + # detect from a post than `unicode` is. + 'auto' if not self.unicode else 'unicode', + }], + } + + # use the list directly + targets = list(self.targets) + + if self.source: + payload['message_globals']['originator'] = self.source + + target = None + while len(targets): + + if self.batch: + # Prepare our payload + payload['messages'][0]['recipients'] = self.targets + + # Reset our targets so we don't keep going. This is required + # because we're in batch mode; we only need to loop once. + targets = [] + + else: + # We're not in a batch mode; so get our next target + # Get our target(s) to notify + target = targets.pop(0) + + # Prepare our payload + payload['messages'][0]['recipients'] = [target] + + # Some Debug Logging + self.logger.debug( + 'D7 Networks POST URL: {} (cert_verify={})'.format( + self.notify_url, self.verify_certificate)) + self.logger.debug('D7 Networks Payload: {}' .format(payload)) + + # 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 not in ( + requests.codes.created, requests.codes.ok): + # We had a problem + status_str = \ + NotifyBase.http_response_code_lookup( + r.status_code, D7NETWORKS_HTTP_ERROR_MAP) + + try: + # Update our status response if we can + json_response = loads(r.content) + status_str = json_response.get('message', status_str) + + except (AttributeError, TypeError, ValueError): + # 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 D7 Networks SMS notification to {}: ' + '{}{}error={}.'.format( + ', '.join(target) if self.batch else target, + 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: + + if self.batch: + self.logger.info( + 'Sent D7 Networks batch SMS notification to ' + '{} target(s).'.format(len(self.targets))) + + else: + self.logger.info( + 'Sent D7 Networks SMS notification to {}.'.format( + target)) + + self.logger.debug( + 'Response Details:\r\n{}'.format(r.content)) + + except requests.RequestException as e: + self.logger.warning( + 'A Connection error occurred sending D7 Networks:%s ' % ( + ', '.join(self.targets)) + '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 = { + 'batch': 'yes' if self.batch else 'no', + 'unicode': 'yes' if self.unicode else 'no', + } + + if self.source: + params['from'] = self.source + + # 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( + [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): + """ + 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 + + if 'token' in results['qsd'] and len(results['qsd']['token']): + results['token'] = \ + NotifyD7Networks.unquote(results['qsd']['token']) + + elif results['user']: + results['token'] = NotifyD7Networks.unquote(results['user']) + + if results['password']: + # Support token containing a colon (:) + results['token'] += \ + ':' + NotifyD7Networks.unquote(results['password']) + + elif results['password']: + # Support token starting with a colon (:) + results['token'] = \ + ':' + NotifyD7Networks.unquote(results['password']) + + # Initialize our targets + results['targets'] = list() + + # The store our first target stored in the hostname + results['targets'].append(NotifyD7Networks.unquote(results['host'])) + + # Get our entries; split_path() looks after unquoting content for us + # by default + results['targets'].extend( + NotifyD7Networks.split_path(results['fullpath'])) + + # Get Batch Mode Flag + results['batch'] = \ + parse_bool(results['qsd'].get('batch', False)) + + # Get Unicode Flag + results['unicode'] = \ + parse_bool(results['qsd'].get('unicode', False)) + + # 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'] += \ + NotifyD7Networks.parse_phone_no(results['qsd']['to']) + + # Support the 'from' and source variable + if 'from' in results['qsd'] and len(results['qsd']['from']): + results['source'] = \ + NotifyD7Networks.unquote(results['qsd']['from']) + + elif 'source' in results['qsd'] and len(results['qsd']['source']): + results['source'] = \ + NotifyD7Networks.unquote(results['qsd']['source']) + + return results diff --git a/lib/apprise/plugins/NotifyDBus.py b/lib/apprise/plugins/NotifyDBus.py new file mode 100644 index 0000000..7d357aa --- /dev/null +++ b/lib/apprise/plugins/NotifyDBus.py @@ -0,0 +1,451 @@ +# -*- 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. + +from __future__ import absolute_import +from __future__ import print_function + +import sys +from .NotifyBase import NotifyBase +from ..common import NotifyImageSize +from ..common import NotifyType +from ..utils import parse_bool +from ..AppriseLocale import gettext_lazy as _ + +# Default our global support flag +NOTIFY_DBUS_SUPPORT_ENABLED = False + +# Image support is dependant on the GdkPixbuf library being available +NOTIFY_DBUS_IMAGE_SUPPORT = False + +# Initialize our mainloops +LOOP_GLIB = None +LOOP_QT = None + + +try: + # dbus essentials + from dbus import SessionBus + from dbus import Interface + from dbus import Byte + from dbus import ByteArray + from dbus import DBusException + + # + # now we try to determine which mainloop(s) we can access + # + + # glib + try: + from dbus.mainloop.glib import DBusGMainLoop + LOOP_GLIB = DBusGMainLoop() + + except ImportError: # pragma: no cover + # No problem + pass + + # qt + try: + from dbus.mainloop.qt import DBusQtMainLoop + LOOP_QT = DBusQtMainLoop(set_as_default=True) + + except ImportError: + # No problem + pass + + # We're good as long as at least one + NOTIFY_DBUS_SUPPORT_ENABLED = ( + LOOP_GLIB is not None or LOOP_QT is not None) + + # ImportError: When using gi.repository you must not import static modules + # like "gobject". Please change all occurrences of "import gobject" to + # "from gi.repository import GObject". + # See: https://bugzilla.gnome.org/show_bug.cgi?id=709183 + if "gobject" in sys.modules: # pragma: no cover + del sys.modules["gobject"] + + try: + # The following is required for Image/Icon loading only + import gi + gi.require_version('GdkPixbuf', '2.0') + from gi.repository import GdkPixbuf + NOTIFY_DBUS_IMAGE_SUPPORT = True + + except (ImportError, ValueError, AttributeError): + # No problem; this will get caught in outer try/catch + + # A ValueError will get thrown upon calling gi.require_version() if + # GDK/GTK isn't installed on the system but gi is. + pass + +except ImportError: + # No problem; we just simply can't support this plugin; we could + # be in microsoft windows, or we just don't have the python-gobject + # library available to us (or maybe one we don't support)? + pass + +# Define our supported protocols and the loop to assign them. +# The key to value pairs are the actual supported schema's matched +# up with the Main Loop they should reference when accessed. +MAINLOOP_MAP = { + 'qt': LOOP_QT, + 'kde': LOOP_QT, + 'glib': LOOP_GLIB, + 'dbus': LOOP_QT if LOOP_QT else LOOP_GLIB, +} + + +# Urgencies +class DBusUrgency: + LOW = 0 + NORMAL = 1 + HIGH = 2 + + +DBUS_URGENCIES = { + # Note: This also acts as a reverse lookup mapping + DBusUrgency.LOW: 'low', + DBusUrgency.NORMAL: 'normal', + DBusUrgency.HIGH: 'high', +} + +DBUS_URGENCY_MAP = { + # Maps against string 'low' + 'l': DBusUrgency.LOW, + # Maps against string 'moderate' + 'm': DBusUrgency.LOW, + # Maps against string 'normal' + 'n': DBusUrgency.NORMAL, + # Maps against string 'high' + 'h': DBusUrgency.HIGH, + # Maps against string 'emergency' + 'e': DBusUrgency.HIGH, + + # Entries to additionally support (so more like DBus's API) + '0': DBusUrgency.LOW, + '1': DBusUrgency.NORMAL, + '2': DBusUrgency.HIGH, +} + + +class NotifyDBus(NotifyBase): + """ + A wrapper for local DBus/Qt Notifications + """ + + # Set our global enabled flag + enabled = NOTIFY_DBUS_SUPPORT_ENABLED + + requirements = { + # Define our required packaging in order to work + 'details': _('libdbus-1.so.x must be installed.') + } + + # The default descriptive name associated with the Notification + service_name = _('DBus Notification') + + # The services URL + service_url = 'http://www.freedesktop.org/Software/dbus/' + + # The default protocols + # Python 3 keys() does not return a list object, it is its own dict_keys() + # object if we were to reference, we wouldn't be backwards compatible with + # Python v2. So converting the result set back into a list makes us + # compatible + # TODO: Review after dropping support for Python 2. + protocol = list(MAINLOOP_MAP.keys()) + + # A URL that takes you to the setup/help of the specific protocol + setup_url = 'https://github.com/caronc/apprise/wiki/Notify_dbus' + + # No throttling required for DBus queries + request_rate_per_sec = 0 + + # Allows the user to specify the NotifyImageSize object + image_size = NotifyImageSize.XY_128 + + # The number of milliseconds to keep the message present for + message_timeout_ms = 13000 + + # Limit results to just the first 10 line otherwise there is just to much + # content to display + body_max_line_count = 10 + + # The following are required to hook into the notifications: + dbus_interface = 'org.freedesktop.Notifications' + dbus_setting_location = '/org/freedesktop/Notifications' + + # Define object templates + templates = ( + '{schema}://', + ) + + # Define our template arguments + template_args = dict(NotifyBase.template_args, **{ + 'urgency': { + 'name': _('Urgency'), + 'type': 'choice:int', + 'values': DBUS_URGENCIES, + 'default': DBusUrgency.NORMAL, + }, + 'priority': { + # Apprise uses 'priority' everywhere; it's just a nice consistent + # feel to be able to use it here as well. Just map the + # value back to 'priority' + 'alias_of': 'urgency', + }, + 'x': { + 'name': _('X-Axis'), + 'type': 'int', + 'min': 0, + 'map_to': 'x_axis', + }, + 'y': { + 'name': _('Y-Axis'), + 'type': 'int', + 'min': 0, + 'map_to': 'y_axis', + }, + 'image': { + 'name': _('Include Image'), + 'type': 'bool', + 'default': True, + 'map_to': 'include_image', + }, + }) + + def __init__(self, urgency=None, x_axis=None, y_axis=None, + include_image=True, **kwargs): + """ + Initialize DBus Object + """ + + super().__init__(**kwargs) + + # Track our notifications + self.registry = {} + + # Store our schema; default to dbus + self.schema = kwargs.get('schema', 'dbus') + + if self.schema not in MAINLOOP_MAP: + msg = 'The schema specified ({}) is not supported.' \ + .format(self.schema) + self.logger.warning(msg) + raise TypeError(msg) + + # The urgency of the message + self.urgency = int( + NotifyDBus.template_args['urgency']['default'] + if urgency is None else + next(( + v for k, v in DBUS_URGENCY_MAP.items() + if str(urgency).lower().startswith(k)), + NotifyDBus.template_args['urgency']['default'])) + + # Our x/y axis settings + if x_axis or y_axis: + try: + self.x_axis = int(x_axis) + self.y_axis = int(y_axis) + + except (TypeError, ValueError): + # Invalid x/y values specified + msg = 'The x,y coordinates specified ({},{}) are invalid.'\ + .format(x_axis, y_axis) + self.logger.warning(msg) + raise TypeError(msg) + else: + self.x_axis = None + self.y_axis = None + + # Track whether we want to add an image to the notification. + self.include_image = include_image + + def send(self, body, title='', notify_type=NotifyType.INFO, **kwargs): + """ + Perform DBus Notification + """ + # Acquire our session + try: + session = SessionBus(mainloop=MAINLOOP_MAP[self.schema]) + + except DBusException as e: + # Handle exception + self.logger.warning('Failed to send DBus notification.') + self.logger.debug(f'DBus Exception: {e}') + return False + + # If there is no title, but there is a body, swap the two to get rid + # of the weird whitespace + if not title: + title = body + body = '' + + # acquire our dbus object + dbus_obj = session.get_object( + self.dbus_interface, + self.dbus_setting_location, + ) + + # Acquire our dbus interface + dbus_iface = Interface( + dbus_obj, + dbus_interface=self.dbus_interface, + ) + + # image path + icon_path = None if not self.include_image \ + else self.image_path(notify_type, extension='.ico') + + # Our meta payload + meta_payload = { + "urgency": Byte(self.urgency) + } + + if not (self.x_axis is None and self.y_axis is None): + # Set x/y access if these were set + meta_payload['x'] = self.x_axis + meta_payload['y'] = self.y_axis + + if NOTIFY_DBUS_IMAGE_SUPPORT and icon_path: + try: + # Use Pixbuf to create the proper image type + image = GdkPixbuf.Pixbuf.new_from_file(icon_path) + + # Associate our image to our notification + meta_payload['icon_data'] = ( + image.get_width(), + image.get_height(), + image.get_rowstride(), + image.get_has_alpha(), + image.get_bits_per_sample(), + image.get_n_channels(), + ByteArray(image.get_pixels()) + ) + + except Exception as e: + self.logger.warning( + "Could not load notification icon (%s).", icon_path) + self.logger.debug(f'DBus Exception: {e}') + + try: + # Always call throttle() before any remote execution is made + self.throttle() + + dbus_iface.Notify( + # Application Identifier + self.app_id, + # Message ID (0 = New Message) + 0, + # Icon (str) - not used + '', + # Title + str(title), + # Body + str(body), + # Actions + list(), + # Meta + meta_payload, + # Message Timeout + self.message_timeout_ms, + ) + + self.logger.info('Sent DBus notification.') + + except Exception as e: + self.logger.warning('Failed to send DBus notification.') + self.logger.debug(f'DBus Exception: {e}') + 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 = { + 'image': 'yes' if self.include_image else 'no', + 'urgency': + DBUS_URGENCIES[self.template_args['urgency']['default']] + if self.urgency not in DBUS_URGENCIES + else DBUS_URGENCIES[self.urgency], + } + + # Extend our parameters + params.update(self.url_parameters(privacy=privacy, *args, **kwargs)) + + # x in (x,y) screen coordinates + if self.x_axis: + params['x'] = str(self.x_axis) + + # y in (x,y) screen coordinates + if self.y_axis: + params['y'] = str(self.y_axis) + + return '{schema}://_/?{params}'.format( + schema=self.schema, + params=NotifyDBus.urlencode(params), + ) + + @staticmethod + def parse_url(url): + """ + There are no parameters nessisary for this protocol; simply having + gnome:// is all you need. This function just makes sure that + is in place. + + """ + + results = NotifyBase.parse_url(url, verify_host=False) + + # Include images with our message + results['include_image'] = \ + parse_bool(results['qsd'].get('image', True)) + + # DBus supports urgency, but we we also support the keyword priority + # so that it is consistent with some of the other plugins + if 'priority' in results['qsd'] and len(results['qsd']['priority']): + # We intentionally store the priority in the urgency section + results['urgency'] = \ + NotifyDBus.unquote(results['qsd']['priority']) + + if 'urgency' in results['qsd'] and len(results['qsd']['urgency']): + results['urgency'] = \ + NotifyDBus.unquote(results['qsd']['urgency']) + + # handle x,y coordinates + if 'x' in results['qsd'] and len(results['qsd']['x']): + results['x_axis'] = NotifyDBus.unquote(results['qsd'].get('x')) + + if 'y' in results['qsd'] and len(results['qsd']['y']): + results['y_axis'] = NotifyDBus.unquote(results['qsd'].get('y')) + + return results diff --git a/lib/apprise/plugins/NotifyDapnet.py b/lib/apprise/plugins/NotifyDapnet.py new file mode 100644 index 0000000..5848b68 --- /dev/null +++ b/lib/apprise/plugins/NotifyDapnet.py @@ -0,0 +1,405 @@ +# -*- 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. + +# To use this plugin, sign up with Hampager (you need to be a licensed +# ham radio operator +# http://www.hampager.de/ +# +# You're done at this point, you only need to know your user/pass that +# you signed up with. + +# The following URLs would be accepted by Apprise: +# - dapnet://{user}:{password}@{callsign} +# - dapnet://{user}:{password}@{callsign1}/{callsign2} + +# Optional parameters: +# - priority (NORMAL or EMERGENCY). Default: NORMAL +# - txgroups --> comma-separated list of DAPNET transmitter +# groups. Default: 'dl-all' +# https://hampager.de/#/transmitters/groups + +from json import dumps + +# The API reference used to build this plugin was documented here: +# https://hampager.de/dokuwiki/doku.php#dapnet_api +# +import requests +from requests.auth import HTTPBasicAuth + +from .NotifyBase import NotifyBase +from ..AppriseLocale import gettext_lazy as _ +from ..URLBase import PrivacyMode +from ..common import NotifyType +from ..utils import is_call_sign +from ..utils import parse_call_sign +from ..utils import parse_list +from ..utils import parse_bool + + +class DapnetPriority: + NORMAL = 0 + EMERGENCY = 1 + + +DAPNET_PRIORITIES = { + DapnetPriority.NORMAL: 'normal', + DapnetPriority.EMERGENCY: 'emergency', +} + + +DAPNET_PRIORITY_MAP = { + # Maps against string 'normal' + 'n': DapnetPriority.NORMAL, + # Maps against string 'emergency' + 'e': DapnetPriority.EMERGENCY, + + # Entries to additionally support (so more like Dapnet's API) + '0': DapnetPriority.NORMAL, + '1': DapnetPriority.EMERGENCY, +} + + +class NotifyDapnet(NotifyBase): + """ + A wrapper for DAPNET / Hampager Notifications + """ + + # The default descriptive name associated with the Notification + service_name = 'Dapnet' + + # The services URL + service_url = 'https://hampager.de/' + + # The default secure protocol + secure_protocol = 'dapnet' + + # A URL that takes you to the setup/help of the specific protocol + setup_url = 'https://github.com/caronc/apprise/wiki/Notify_dapnet' + + # Dapnet uses the http protocol with JSON requests + notify_url = 'http://www.hampager.de:8080/calls' + + # The maximum length of the body + body_maxlen = 80 + + # A title can not be used for Dapnet Messages. Setting this to zero will + # cause any title (if defined) to get placed into the message body. + title_maxlen = 0 + + # The maximum amount of emails that can reside within a single transmission + default_batch_size = 50 + + # Define object templates + templates = ('{schema}://{user}:{password}@{targets}',) + + # Define our template tokens + template_tokens = dict( + NotifyBase.template_tokens, + **{ + 'user': { + 'name': _('User Name'), + 'type': 'string', + 'required': True, + }, + 'password': { + 'name': _('Password'), + 'type': 'string', + 'private': True, + 'required': True, + }, + 'target_callsign': { + 'name': _('Target Callsign'), + 'type': 'string', + 'regex': ( + r'^[a-z0-9]{2,5}(-[a-z0-9]{1,2})?$', 'i', + ), + 'map_to': 'targets', + }, + 'targets': { + 'name': _('Targets'), + 'type': 'list:string', + 'required': True, + }, + } + ) + + # Define our template arguments + template_args = dict( + NotifyBase.template_args, + **{ + 'to': { + 'name': _('Target Callsign'), + 'type': 'string', + 'map_to': 'targets', + }, + 'priority': { + 'name': _('Priority'), + 'type': 'choice:int', + 'values': DAPNET_PRIORITIES, + 'default': DapnetPriority.NORMAL, + }, + 'txgroups': { + 'name': _('Transmitter Groups'), + 'type': 'string', + 'default': 'dl-all', + 'private': True, + }, + 'batch': { + 'name': _('Batch Mode'), + 'type': 'bool', + 'default': False, + }, + } + ) + + def __init__(self, targets=None, priority=None, txgroups=None, + batch=False, **kwargs): + """ + Initialize Dapnet Object + """ + super().__init__(**kwargs) + + # Parse our targets + self.targets = list() + + # The Priority of the message + self.priority = int( + NotifyDapnet.template_args['priority']['default'] + if priority is None else + next(( + v for k, v in DAPNET_PRIORITY_MAP.items() + if str(priority).lower().startswith(k)), + NotifyDapnet.template_args['priority']['default'])) + + if not (self.user and self.password): + msg = 'A Dapnet user/pass was not provided.' + self.logger.warning(msg) + raise TypeError(msg) + + # Get the transmitter group + self.txgroups = parse_list( + NotifyDapnet.template_args['txgroups']['default'] + if not txgroups else txgroups) + + # Prepare Batch Mode Flag + self.batch = batch + + for target in parse_call_sign(targets): + # Validate targets and drop bad ones: + result = is_call_sign(target) + if not result: + self.logger.warning( + 'Dropping invalid Amateur radio call sign ({}).'.format( + target), + ) + continue + + # Store callsign without SSID and ignore duplicates + if result['callsign'] not in self.targets: + self.targets.append(result['callsign']) + + return + + def send(self, body, title='', notify_type=NotifyType.INFO, **kwargs): + """ + Perform Dapnet Notification + """ + + if not self.targets: + # There is no one to email; we're done + self.logger.warning( + 'There are no Amateur radio callsigns to notify') + return False + + # Send in batches if identified to do so + batch_size = 1 if not self.batch else self.default_batch_size + + headers = { + 'User-Agent': self.app_id, + 'Content-Type': 'application/json; charset=utf-8', + } + + # error tracking (used for function return) + has_error = False + + # Create a copy of the targets list + targets = list(self.targets) + + for index in range(0, len(targets), batch_size): + + # prepare JSON payload + payload = { + 'text': body, + 'callSignNames': targets[index:index + batch_size], + 'transmitterGroupNames': self.txgroups, + 'emergency': (self.priority == DapnetPriority.EMERGENCY), + } + + self.logger.debug('DAPNET POST URL: %s' % self.notify_url) + self.logger.debug('DAPNET Payload: %s' % dumps(payload)) + + # 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, + auth=HTTPBasicAuth( + username=self.user, password=self.password), + verify=self.verify_certificate, + timeout=self.request_timeout, + ) + if r.status_code != requests.codes.created: + # We had a problem + + self.logger.warning( + 'Failed to send DAPNET notification {} to {}: ' + 'error={}.'.format( + payload['text'], + ' to {}'.format(self.targets), + r.status_code + ) + ) + + self.logger.debug( + 'Response Details:\r\n{}'.format(r.content)) + + # Mark our failure + has_error = True + + else: + self.logger.info( + 'Sent \'{}\' DAPNET notification {}'.format( + payload['text'], 'to {}'.format(self.targets) + ) + ) + + except requests.RequestException as e: + self.logger.warning( + 'A Connection error occurred sending DAPNET ' + 'notification to {}'.format(self.targets) + ) + self.logger.debug('Socket Exception: %s' % str(e)) + + # Mark our failure + has_error = True + + 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 = { + 'priority': + DAPNET_PRIORITIES[self.template_args['priority']['default']] + if self.priority not in DAPNET_PRIORITIES + else DAPNET_PRIORITIES[self.priority], + 'batch': 'yes' if self.batch else 'no', + 'txgroups': ','.join(self.txgroups), + } + + # Extend our parameters + params.update(self.url_parameters(privacy=privacy, *args, **kwargs)) + + # Setup Authentication + auth = '{user}:{password}@'.format( + user=NotifyDapnet.quote(self.user, safe=""), + password=self.pprint( + self.password, privacy, mode=PrivacyMode.Secret, safe='' + ), + ) + + return '{schema}://{auth}{targets}?{params}'.format( + schema=self.secure_protocol, + auth=auth, + targets='/'.join([self.pprint(x, privacy, safe='') + for x in self.targets]), + 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): + """ + 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 + + # All elements are targets + results['targets'] = [NotifyDapnet.unquote(results['host'])] + + # All entries after the hostname are additional targets + results['targets'].extend(NotifyDapnet.split_path(results['fullpath'])) + + # 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'] += \ + NotifyDapnet.parse_list(results['qsd']['to']) + + # Set our priority + if 'priority' in results['qsd'] and len(results['qsd']['priority']): + results['priority'] = \ + NotifyDapnet.unquote(results['qsd']['priority']) + + # Check for one or multiple transmitter groups (comma separated) + # and split them up, when necessary + if 'txgroups' in results['qsd']: + results['txgroups'] = \ + [x.lower() for x in + NotifyDapnet.parse_list(results['qsd']['txgroups'])] + + # Get Batch Mode Flag + results['batch'] = \ + parse_bool(results['qsd'].get( + 'batch', NotifyDapnet.template_args['batch']['default'])) + + return results diff --git a/lib/apprise/plugins/NotifyDingTalk.py b/lib/apprise/plugins/NotifyDingTalk.py new file mode 100644 index 0000000..91bfcd6 --- /dev/null +++ b/lib/apprise/plugins/NotifyDingTalk.py @@ -0,0 +1,358 @@ +# -*- 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 time +import hmac +import hashlib +import base64 +import requests +from json import dumps + +from .NotifyBase import NotifyBase +from ..URLBase import PrivacyMode +from ..common import NotifyFormat +from ..common import NotifyType +from ..utils import parse_list +from ..utils import validate_regex +from ..AppriseLocale import gettext_lazy as _ + +# Register at https://dingtalk.com +# - Download their PC based software as it is the only way you can create +# a custom robot. You can create a custom robot per group. You will +# be provided an access_token that Apprise will need. + +# Syntax: +# dingtalk://{access_token}/ +# dingtalk://{access_token}/{optional_phone_no} +# dingtalk://{access_token}/{phone_no_1}/{phone_no_2}/{phone_no_N/ + +# Some Phone Number Detection +IS_PHONE_NO = re.compile(r'^\+?(?P[0-9\s)(+-]+)\s*$') + + +class NotifyDingTalk(NotifyBase): + """ + A wrapper for DingTalk Notifications + """ + + # The default descriptive name associated with the Notification + service_name = 'DingTalk' + + # The services URL + service_url = 'https://www.dingtalk.com/' + + # All notification requests are secure + secure_protocol = 'dingtalk' + + # A URL that takes you to the setup/help of the specific protocol + setup_url = 'https://github.com/caronc/apprise/wiki/Notify_dingtalk' + + # DingTalk API + notify_url = 'https://oapi.dingtalk.com/robot/send?access_token={token}' + + # Do not set title_maxlen as it is set in a property value below + # since the length varies depending if we are doing a markdown + # based message or a text based one. + # title_maxlen = see below @propery defined + + # Define object templates + templates = ( + '{schema}://{token}/', + '{schema}://{token}/{targets}/', + '{schema}://{secret}@{token}/', + '{schema}://{secret}@{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]+$', 'i'), + }, + 'secret': { + 'name': _('Secret'), + 'type': 'string', + 'private': True, + 'regex': (r'^[a-z0-9]+$', 'i'), + }, + 'target_phone_no': { + 'name': _('Target Phone No'), + 'type': 'string', + 'map_to': 'targets', + }, + 'targets': { + 'name': _('Targets'), + 'type': 'list:string', + }, + }) + + # Define our template arguments + template_args = dict(NotifyBase.template_args, **{ + 'to': { + 'alias_of': 'targets', + }, + 'token': { + 'alias_of': 'token', + }, + 'secret': { + 'alias_of': 'secret', + }, + }) + + def __init__(self, token, targets=None, secret=None, **kwargs): + """ + Initialize DingTalk 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 DingTalk API Token ' \ + '({}) was specified.'.format(token) + self.logger.warning(msg) + raise TypeError(msg) + + self.secret = None + if secret: + self.secret = validate_regex( + secret, *self.template_tokens['secret']['regex']) + if not self.secret: + msg = 'An invalid DingTalk Secret ' \ + '({}) was specified.'.format(token) + self.logger.warning(msg) + raise TypeError(msg) + + # Parse our targets + self.targets = list() + + for target in parse_list(targets): + # Validate targets and drop bad ones: + result = IS_PHONE_NO.match(target) + if result: + # Further check our phone # for it's digit count + result = ''.join(re.findall(r'\d+', result.group('phone'))) + if len(result) < 11 or len(result) > 14: + self.logger.warning( + 'Dropped invalid phone # ' + '({}) specified.'.format(target), + ) + continue + + # store valid phone number + self.targets.append(result) + continue + + self.logger.warning( + 'Dropped invalid phone # ' + '({}) specified.'.format(target), + ) + + return + + def get_signature(self): + """ + Calculates time-based signature so that we can send arbitrary messages. + """ + timestamp = str(round(time.time() * 1000)) + secret_enc = self.secret.encode('utf-8') + str_to_sign_enc = \ + "{}\n{}".format(timestamp, self.secret).encode('utf-8') + hmac_code = hmac.new( + secret_enc, str_to_sign_enc, digestmod=hashlib.sha256).digest() + signature = NotifyDingTalk.quote(base64.b64encode(hmac_code), safe='') + return timestamp, signature + + def send(self, body, title='', notify_type=NotifyType.INFO, **kwargs): + """ + Perform DingTalk Notification + """ + + payload = { + 'msgtype': 'text', + 'at': { + 'atMobiles': self.targets, + 'isAtAll': False, + } + } + + if self.notify_format == NotifyFormat.MARKDOWN: + payload['markdown'] = { + 'title': title, + 'text': body, + } + + else: + payload['text'] = { + 'content': body, + } + + # Our Notification URL + notify_url = self.notify_url.format(token=self.token) + + params = None + if self.secret: + timestamp, signature = self.get_signature() + params = { + 'timestamp': timestamp, + 'sign': signature, + } + + # Prepare our headers + headers = { + 'User-Agent': self.app_id, + 'Content-Type': 'application/json' + } + + # Some Debug Logging + self.logger.debug('DingTalk URL: {} (cert_verify={})'.format( + notify_url, self.verify_certificate)) + self.logger.debug('DingTalk Payload: {}' .format(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, + params=params, + verify=self.verify_certificate, + ) + + if r.status_code != requests.codes.ok: + # We had a problem + status_str = \ + NotifyDingTalk.http_response_code_lookup( + r.status_code) + + self.logger.warning( + 'Failed to send DingTalk 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 DingTalk notification.') + + except requests.RequestException as e: + self.logger.warning( + 'A Connection error occured sending DingTalk ' + 'notification.' + ) + self.logger.debug('Socket Exception: %s' % str(e)) + return False + + return True + + @property + def title_maxlen(self): + """ + The title isn't used when not in markdown mode. + """ + return NotifyBase.title_maxlen \ + if self.notify_format == NotifyFormat.MARKDOWN else 0 + + def url(self, privacy=False, *args, **kwargs): + """ + Returns the URL built dynamically based on specified arguments. + """ + + # Define any arguments set + args = { + 'format': self.notify_format, + 'overflow': self.overflow_mode, + 'verify': 'yes' if self.verify_certificate else 'no', + } + + return '{schema}://{secret}{token}/{targets}/?{args}'.format( + schema=self.secure_protocol, + secret='' if not self.secret else '{}@'.format(self.pprint( + self.secret, privacy, mode=PrivacyMode.Secret, safe='')), + token=self.pprint(self.token, privacy, safe=''), + targets='/'.join( + [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): + """ + 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 load the results + return results + + results['token'] = NotifyDingTalk.unquote(results['host']) + + # if a user has been defined, use it's value as the secret + if results.get('user'): + results['secret'] = results.get('user') + + # Get our entries; split_path() looks after unquoting content for us + # by default + results['targets'] = NotifyDingTalk.split_path(results['fullpath']) + + # Support the use of the `token` keyword argument + if 'token' in results['qsd'] and len(results['qsd']['token']): + results['token'] = \ + NotifyDingTalk.unquote(results['qsd']['token']) + + # Support the use of the `secret` keyword argument + if 'secret' in results['qsd'] and len(results['qsd']['secret']): + results['secret'] = \ + NotifyDingTalk.unquote(results['qsd']['secret']) + + # 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'] += \ + NotifyDingTalk.parse_list(results['qsd']['to']) + + return results diff --git a/lib/apprise/plugins/NotifyDiscord.py b/lib/apprise/plugins/NotifyDiscord.py new file mode 100644 index 0000000..f87b669 --- /dev/null +++ b/lib/apprise/plugins/NotifyDiscord.py @@ -0,0 +1,709 @@ +# -*- 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. + +# For this to work correctly you need to create a webhook. To do this just +# click on the little gear icon next to the channel you're part of. From +# here you'll be able to access the Webhooks menu and create a new one. +# +# When you've completed, you'll get a URL that looks a little like this: +# https://discord.com/api/webhooks/417429632418316298/\ +# JHZ7lQml277CDHmQKMHI8qBe7bk2ZwO5UKjCiOAF7711o33MyqU344Qpgv7YTpadV_js +# +# Simplified, it looks like this: +# https://discord.com/api/webhooks/WEBHOOK_ID/WEBHOOK_TOKEN +# +# This plugin will simply work using the url of: +# discord://WEBHOOK_ID/WEBHOOK_TOKEN +# +# API Documentation on Webhooks: +# - https://discord.com/developers/docs/resources/webhook +# +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 +from ..common import NotifyFormat +from ..common import NotifyType +from ..utils import parse_bool +from ..utils import validate_regex +from ..AppriseLocale import gettext_lazy as _ +from ..attachment.AttachBase import AttachBase + + +class NotifyDiscord(NotifyBase): + """ + A wrapper to Discord Notifications + + """ + # The default descriptive name associated with the Notification + service_name = 'Discord' + + # The services URL + service_url = 'https://discord.com/' + + # The default secure protocol + secure_protocol = 'discord' + + # A URL that takes you to the setup/help of the specific protocol + setup_url = 'https://github.com/caronc/apprise/wiki/Notify_discord' + + # 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 + + # Discord has a limit of the number of fields you can include in an + # embeds message. This value allows the discord message to safely + # break into multiple messages to handle these cases. + discord_max_fields = 10 + + # Define object templates + templates = ( + '{schema}://{webhook_id}/{webhook_token}', + '{schema}://{botname}@{webhook_id}/{webhook_token}', + ) + + # Define our template tokens + template_tokens = dict(NotifyBase.template_tokens, **{ + 'botname': { + 'name': _('Bot Name'), + 'type': 'string', + 'map_to': 'user', + }, + 'webhook_id': { + 'name': _('Webhook ID'), + 'type': 'string', + 'private': True, + 'required': True, + }, + 'webhook_token': { + 'name': _('Webhook Token'), + 'type': 'string', + 'private': True, + 'required': True, + }, + }) + + # Define our template arguments + template_args = dict(NotifyBase.template_args, **{ + 'tts': { + 'name': _('Text To Speech'), + 'type': 'bool', + 'default': False, + }, + 'avatar': { + 'name': _('Avatar Image'), + 'type': 'bool', + 'default': True, + }, + 'avatar_url': { + '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': { + 'name': _('Thread ID'), + 'type': 'string', + }, + 'footer': { + 'name': _('Display Footer'), + 'type': 'bool', + 'default': False, + }, + 'footer_logo': { + 'name': _('Footer Logo'), + 'type': 'bool', + 'default': True, + }, + 'fields': { + 'name': _('Use Fields'), + 'type': 'bool', + 'default': True, + }, + 'image': { + 'name': _('Include Image'), + 'type': 'bool', + 'default': False, + 'map_to': 'include_image', + }, + }) + + def __init__(self, webhook_id, webhook_token, tts=False, avatar=True, + footer=False, footer_logo=True, include_image=False, + fields=True, avatar_url=None, href=None, thread=None, + **kwargs): + """ + Initialize Discord Object + + """ + super().__init__(**kwargs) + + # Webhook ID (associated with project) + self.webhook_id = validate_regex(webhook_id) + if not self.webhook_id: + msg = 'An invalid Discord Webhook ID ' \ + '({}) was specified.'.format(webhook_id) + self.logger.warning(msg) + raise TypeError(msg) + + # Webhook Token (associated with project) + self.webhook_token = validate_regex(webhook_token) + if not self.webhook_token: + msg = 'An invalid Discord Webhook Token ' \ + '({}) was specified.'.format(webhook_token) + self.logger.warning(msg) + raise TypeError(msg) + + # Text To Speech + self.tts = tts + + # Over-ride Avatar Icon + self.avatar = avatar + + # Place a footer + self.footer = footer + + # include a footer_logo in footer + self.footer_logo = footer_logo + + # Place a thumbnail image inline with the message body + self.include_image = include_image + + # Use Fields + self.fields = fields + + # Specified Thread ID + self.thread_id = thread + + # Avatar URL + # This allows a user to provide an over-ride to the otherwise + # 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, + **kwargs): + """ + Perform Discord Notification + """ + + payload = { + # Text-To-Speech + 'tts': self.tts, + + # If Text-To-Speech is set to True, then we do not want to wait + # for the whole message before continuing. Otherwise, we wait + 'wait': self.tts is False, + } + + # Acquire image_url + image_url = self.image_url(notify_type) + + if self.avatar and (image_url or self.avatar_url): + payload['avatar_url'] = \ + self.avatar_url if self.avatar_url else image_url + + if self.user: + # Optionally override the default username of the webhook + payload['username'] = self.user + + # Associate our thread_id with our message + params = {'thread_id': self.thread_id} if self.thread_id else None + + if body: + # 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.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({ + # Text-To-Speech + 'tts': False, + # Wait until the upload has posted itself before continuing + 'wait': True, + }) + + # Remove our text/title based content for attachment use + if 'embeds' in payload: + # Markdown + del payload['embeds'] + + if 'content' in payload: + # Markdown + del payload['content'] + + # Send our attachments + for attachment in attach: + self.logger.info( + 'Posting Discord Attachment {}'.format(attachment.name)) + 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, params=None, rate_limit=1, + **kwargs): + """ + Wrapper to the requests (post) object + """ + + # Our headers + headers = { + 'User-Agent': self.app_id, + } + + # Construct Notify URL + notify_url = '{0}/{1}/{2}'.format( + self.notify_url, + self.webhook_id, + self.webhook_token, + ) + + self.logger.debug('Discord POST URL: %s (cert_verify=%r)' % ( + notify_url, self.verify_certificate, + )) + self.logger.debug('Discord Payload: %s' % str(payload)) + + # 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): + if not attach: + # We could not access the attachment + self.logger.error( + 'Could not access attachment {}.'.format( + attach.url(privacy=True))) + return False + + self.logger.debug( + 'Posting Discord attachment {}'.format( + attach.url(privacy=True))) + + # Our attachment path (if specified) + files = None + try: + + # Open our attachment path if required: + if attach: + files = {'file': (attach.name, open(attach.path, 'rb'))} + + else: + headers['Content-Type'] = 'application/json; charset=utf-8' + + 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): + + # We had a problem + 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( + attach.name if attach else '', + 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 Discord {}.'.format( + 'attachment' if attach else 'notification')) + + except requests.RequestException as e: + self.logger.warning( + 'A Connection error occurred posting {}to Discord.'.format( + attach.name if attach else '')) + self.logger.debug('Socket Exception: %s' % str(e)) + return False + + except (OSError, IOError) as e: + self.logger.warning( + 'An I/O error occurred while reading {}.'.format( + attach.name if attach else 'attachment')) + self.logger.debug('I/O Exception: %s' % str(e)) + return False + + 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 True + + def url(self, privacy=False, *args, **kwargs): + """ + Returns the URL built dynamically based on specified arguments. + """ + + # Define any URL parameters + params = { + 'tts': 'yes' if self.tts else 'no', + 'avatar': 'yes' if self.avatar else 'no', + 'footer': 'yes' if self.footer else 'no', + 'footer_logo': 'yes' if self.footer_logo else 'no', + 'image': 'yes' if self.include_image else 'no', + 'fields': 'yes' if self.fields else 'no', + } + + 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 + + # Extend our parameters + params.update(self.url_parameters(privacy=privacy, *args, **kwargs)) + + return '{schema}://{webhook_id}/{webhook_token}/?{params}'.format( + schema=self.secure_protocol, + webhook_id=self.pprint(self.webhook_id, privacy, safe=''), + webhook_token=self.pprint(self.webhook_token, privacy, safe=''), + params=NotifyDiscord.urlencode(params), + ) + + @staticmethod + def parse_url(url): + """ + Parses the URL and returns enough arguments that can allow + us to re-instantiate this object. + + Syntax: + discord://webhook_id/webhook_token + + """ + 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 webhook ID + webhook_id = NotifyDiscord.unquote(results['host']) + + # Now fetch our tokens + try: + webhook_token = \ + NotifyDiscord.split_path(results['fullpath'])[0] + + except IndexError: + # Force some bad values that will get caught + # in parsing later + webhook_token = None + + results['webhook_id'] = webhook_id + results['webhook_token'] = webhook_token + + # Text To Speech + results['tts'] = parse_bool(results['qsd'].get('tts', False)) + + # Use sections + # effectively detect multiple fields and break them off + # into sections + results['fields'] = parse_bool(results['qsd'].get('fields', True)) + + # Use Footer + results['footer'] = parse_bool(results['qsd'].get('footer', False)) + + # Use Footer Logo + results['footer_logo'] = \ + parse_bool(results['qsd'].get('footer_logo', True)) + + # Update Avatar Icon + results['avatar'] = parse_bool(results['qsd'].get('avatar', True)) + + # Boolean to include an image or not + results['include_image'] = parse_bool(results['qsd'].get( + 'image', NotifyDiscord.template_args['image']['default'])) + + # Extract avatar url if it was specified + if 'avatar_url' in results['qsd']: + 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 + + @staticmethod + def parse_native_url(url): + """ + Support https://discord.com/api/webhooks/WEBHOOK_ID/WEBHOOK_TOKEN + Support Legacy URL as well: + https://discordapp.com/api/webhooks/WEBHOOK_ID/WEBHOOK_TOKEN + """ + + result = re.match( + r'^https?://discord(app)?\.com/api/webhooks/' + r'(?P[0-9]+)/' + r'(?P[A-Z0-9_-]+)/?' + r'(?P\?.+)?$', url, re.I) + + if result: + return NotifyDiscord.parse_url( + '{schema}://{webhook_id}/{webhook_token}/{params}'.format( + schema=NotifyDiscord.secure_protocol, + webhook_id=result.group('webhook_id'), + webhook_token=result.group('webhook_token'), + params='' if not result.group('params') + else result.group('params'))) + + return None + + @staticmethod + def extract_markdown_sections(markdown): + """ + Takes a string in a markdown type format and extracts + the headers and their corresponding sections into individual + fields that get passed as an embed entry to Discord. + + """ + # Search for any header information found without it's own section + # identifier + match = re.match( + r'^\s*(?P[^\s#]+.*?)(?=\s*$|[\r\n]+\s*#)', + markdown, flags=re.S) + + description = match.group('desc').strip() if match else '' + if description: + # Strip description from our string since it has been handled + # now. + markdown = re.sub(re.escape(description), '', markdown, count=1) + + regex = re.compile( + r'\s*#[# \t\v]*(?P[^\n]+)(\n|\s*$)' + r'\s*((?P[^#].+?)(?=\s*$|[\r\n]+\s*#))?', flags=re.S) + + common = regex.finditer(markdown) + fields = list() + for el in common: + d = el.groupdict() + + fields.append({ + 'name': d.get('name', '').strip('#`* \r\n\t\v'), + 'value': '```{}\n{}```'.format( + 'md' if d.get('value') else '', + d.get('value').strip() + '\n' if d.get('value') else '', + ), + }) + + return description, fields diff --git a/lib/apprise/plugins/NotifyEmail.py b/lib/apprise/plugins/NotifyEmail.py new file mode 100644 index 0000000..db70c8e --- /dev/null +++ b/lib/apprise/plugins/NotifyEmail.py @@ -0,0 +1,1090 @@ +# -*- 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 dataclasses +import re +import smtplib +import typing as t +from email.mime.text import MIMEText +from email.mime.application import MIMEApplication +from email.mime.multipart import MIMEMultipart +from email.utils import formataddr, make_msgid +from email.header import Header +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 +from ..common import NotifyFormat, NotifyType +from ..conversion import convert_between +from ..utils import is_email, parse_emails +from ..AppriseLocale import gettext_lazy as _ +from ..logger import logger + +# Globally Default encoding mode set to Quoted Printable. +charset.add_charset('utf-8', charset.QP, charset.QP, 'utf-8') + + +class WebBaseLogin: + """ + This class is just used in conjunction of the default emailers + to best formulate a login to it using the data detected + """ + # User Login must be Email Based + EMAIL = 'Email' + + # User Login must UserID Based + USERID = 'UserID' + + +# Secure Email Modes +class SecureMailMode: + INSECURE = "insecure" + SSL = "ssl" + STARTTLS = "starttls" + + +# Define all of the secure modes (used during validation) +SECURE_MODES = { + SecureMailMode.STARTTLS: { + 'default_port': 587, + }, + SecureMailMode.SSL: { + 'default_port': 465, + }, + SecureMailMode.INSECURE: { + 'default_port': 25, + }, +} + +# To attempt to make this script stupid proof, if we detect an email address +# that is part of the this table, we can pre-use a lot more defaults if they +# aren't otherwise specified on the users input. +EMAIL_TEMPLATES = ( + # Google GMail + ( + 'Google Mail', + re.compile( + r'^((?P