diff options
91 files changed, 11622 insertions, 1428 deletions
diff --git a/bazarr/notifier.py b/bazarr/notifier.py index c409eef45..7a24303ee 100644 --- a/bazarr/notifier.py +++ b/bazarr/notifier.py @@ -23,11 +23,11 @@ def update_notifier(): notifiers_current.append([notifier['name']]) for x in results['schemas']: - if [x['service_name']] not in notifiers_current: - notifiers_new.append({'name': x['service_name'], 'enabled': 0}) - logging.debug('Adding new notifier agent: ' + x['service_name']) + if [str(x['service_name'])] not in notifiers_current: + notifiers_new.append({'name': str(x['service_name']), 'enabled': 0}) + logging.debug('Adding new notifier agent: ' + str(x['service_name'])) else: - notifiers_old.append([x['service_name']]) + notifiers_old.append([str(x['service_name'])]) notifiers_to_delete = [item for item in notifiers_current if item not in notifiers_old] diff --git a/libs/apprise/Apprise.py b/libs/apprise/Apprise.py index b95da22a7..8930b2a77 100644 --- a/libs/apprise/Apprise.py +++ b/libs/apprise/Apprise.py @@ -34,6 +34,7 @@ from .common import MATCH_ALL_TAG 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 @@ -58,13 +59,15 @@ class Apprise(object): """ - def __init__(self, servers=None, asset=None, debug=False): + 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 @@ -87,6 +90,11 @@ class Apprise(object): # 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): """ @@ -116,9 +124,14 @@ class Apprise(object): # Initialize our result set results = None + # Prepare our Asset Object + asset = asset if isinstance(asset, AppriseAsset) else AppriseAsset() + if isinstance(url, six.string_types): # Acquire our url tokens - results = plugins.url_to_dict(url) + 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. @@ -132,25 +145,40 @@ class Apprise(object): # 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()]))) + 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()]))) + 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('Invalid URL specified: {}'.format(url)) + logger.error( + 'An invalid URL type (%s) was specified for instantiation', + type(url)) + return None + + if not plugins.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)) - # Prepare our Asset Object - results['asset'] = \ - asset if isinstance(asset, AppriseAsset) else AppriseAsset() + # Set our Asset Object + results['asset'] = asset if suppress_exceptions: try: @@ -159,14 +187,21 @@ class Apprise(object): plugin = plugins.SCHEMA_MAP[results['schema']](**results) # Create log entry of loaded URL - logger.debug('Loaded {} URL: {}'.format( - plugins.SCHEMA_MAP[results['schema']].service_name, - plugin.url())) + logger.debug( + 'Loaded {} URL: {}'.format( + plugins.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( - plugins.SCHEMA_MAP[results['schema']].service_name, url)) + logger.error( + 'Could not load {} URL: {}'.format( + plugins.SCHEMA_MAP[results['schema']].service_name, + loggable_url)) return None else: @@ -174,6 +209,24 @@ class Apprise(object): # URL information but don't wrap it in a try catch plugin = plugins.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): @@ -286,7 +339,8 @@ class Apprise(object): return def notify(self, body, title='', notify_type=NotifyType.INFO, - body_format=None, tag=MATCH_ALL_TAG, attach=None): + body_format=None, tag=MATCH_ALL_TAG, attach=None, + interpret_escapes=None): """ Send a notification to all of the plugins previously loaded. @@ -306,47 +360,158 @@ class Apprise(object): Attach can contain a list of attachment URLs. attach can also be represented by a 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. """ - if len(self) == 0: - # Nothing to notify + if ASYNCIO_SUPPORT: + return py3compat.asyncio.tosync( + self.async_notify( + body, title, + notify_type=notify_type, body_format=body_format, + tag=tag, attach=attach, + interpret_escapes=interpret_escapes, + ), + debug=self.debug + ) + + else: + try: + results = list( + self._notifyall( + Apprise._notifyhandler, + body, title, + notify_type=notify_type, body_format=body_format, + tag=tag, attach=attach, + interpret_escapes=interpret_escapes, + ) + ) + + except TypeError: + # No notifications sent, and there was an internal error. + return False + + else: + if len(results) > 0: + # All notifications sent, return False if any failed. + return all(results) + + else: + # No notifications sent. + return None + + def async_notify(self, *args, **kwargs): + """ + Send a notification to all of the plugins previously loaded, for + asynchronous callers. This method is an async method that should be + awaited on, even if it is missing the async keyword in its signature. + (This is omitted to preserve syntax compatibility with Python 2.) + + The arguments are identical to those of Apprise.notify(). This method + is not available in Python 2. + """ + + try: + coroutines = list( + self._notifyall( + Apprise._notifyhandlerasync, *args, **kwargs)) + + except TypeError: + # No notifications sent, and there was an internal error. + return py3compat.asyncio.toasyncwrap(False) + + else: + if len(coroutines) > 0: + # All notifications sent, return False if any failed. + return py3compat.asyncio.notify(coroutines) + + else: + # No notifications sent. + return py3compat.asyncio.toasyncwrap(None) + + @staticmethod + def _notifyhandler(server, **kwargs): + """ + The synchronous notification sender. Returns True if the notification + sent successfully. + """ + + try: + # Send notification + return server.notify(**kwargs) + + except TypeError: + # These our our internally thrown notifications + return 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") return False - # Initialize our return result which only turns to True if we send - # at least one valid notification - status = None + @staticmethod + def _notifyhandlerasync(server, **kwargs): + """ + The asynchronous notification sender. Returns a coroutine that yields + True if the notification sent successfully. + """ + + if server.asset.async_mode: + return server.async_notify(**kwargs) + + else: + # Send the notification immediately, and wrap the result in a + # coroutine. + status = Apprise._notifyhandler(server, **kwargs) + return py3compat.asyncio.toasyncwrap(status) + + def _notifyall(self, handler, body, title='', notify_type=NotifyType.INFO, + body_format=None, tag=MATCH_ALL_TAG, attach=None, + interpret_escapes=None): + """ + Creates notifications for all of the plugins loaded. + + Returns a generator that calls handler for each notification. The first + and only argument supplied to handler is the server, and the keyword + arguments are exactly as they would be passed to server.notify(). + """ + + if len(self) == 0: + # Nothing to notify + raise TypeError("No service(s) to notify") if not (title or body): - return False + raise TypeError("No message content specified to deliver") + + if six.PY2: + # Python 2.7.x Unicode Character Handling + # Ensure we're working with utf-8 + if isinstance(title, unicode): # noqa: F821 + title = title.encode('utf-8') + + if isinstance(body, unicode): # noqa: F821 + body = body.encode('utf-8') # Tracks conversions conversion_map = dict() # 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 - return False + 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 - # for asyncio support; we track a list of our servers to notify - # sequentially - coroutines = [] + # 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): - if status is None: - # We have at least one server to notify; change status - # to be a default value of True from now (purely an - # initialiation at this point) - status = True - # 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 @@ -396,48 +561,59 @@ class Apprise(object): # Store entry directly conversion_map[server.notify_format] = body - if ASYNCIO_SUPPORT and server.asset.async_mode: - # Build a list of servers requiring notification - # that will be triggered asynchronously afterwards - coroutines.append(server.async_notify( - body=conversion_map[server.notify_format], - title=title, - notify_type=notify_type, - attach=attach)) - - # We gather at this point and notify at the end - continue - - try: - # Send notification - if not server.notify( - body=conversion_map[server.notify_format], - title=title, - notify_type=notify_type, - attach=attach): - - # Toggle our return status flag - status = False - - except TypeError: - # These our our internally thrown notifications - status = 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("Notification Exception") - status = False - - if coroutines: - # perform our async notification(s) - if not py3compat.asyncio.notify(coroutines, debug=self.debug): - # Toggle our status only if we had a failure - status = False + if interpret_escapes: + # + # Escape our content + # - return status + try: + # Added overhead required due to Python 3 Encoding Bug + # identified here: https://bugs.python.org/issue21331 + conversion_map[server.notify_format] = \ + conversion_map[server.notify_format]\ + .encode('ascii', 'backslashreplace')\ + .decode('unicode-escape') - def details(self, lang=None): + except UnicodeDecodeError: # pragma: no cover + # This occurs using a very old verion of Python 2.7 such + # as the one that ships with CentOS/RedHat 7.x (v2.7.5). + conversion_map[server.notify_format] = \ + conversion_map[server.notify_format] \ + .decode('string_escape') + + except AttributeError: + # Must be of string type + logger.error('Failed to escape message body') + raise TypeError + + if title: + try: + # Added overhead required due to Python 3 Encoding Bug + # identified here: https://bugs.python.org/issue21331 + title = title\ + .encode('ascii', 'backslashreplace')\ + .decode('unicode-escape') + + except UnicodeDecodeError: # pragma: no cover + # This occurs using a very old verion of Python 2.7 + # such as the one that ships with CentOS/RedHat 7.x + # (v2.7.5). + title = title.decode('string_escape') + + except AttributeError: + # Must be of string type + logger.error('Failed to escape message title') + raise TypeError + + yield handler( + server, + body=conversion_map[server.notify_format], + title=title, + notify_type=notify_type, + attach=attach + ) + + def details(self, lang=None, show_requirements=False, show_disabled=False): """ Returns the details associated with the Apprise object @@ -453,8 +629,27 @@ class Apprise(object): 'asset': self.asset.details(), } - # to add it's mapping to our hash table for plugin in set(plugins.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 + } + + # 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) @@ -466,31 +661,35 @@ class Apprise(object): if isinstance(secure_protocols, six.string_types): 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 - details = plugins.details(plugin) + 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): - details = plugins.details(plugin) + content['details'] = plugins.details(plugin) + if show_requirements: + content['requirements'] = plugins.requirements(plugin) # Build our response object - response['schemas'].append({ - 'service_name': getattr(plugin, 'service_name', None), - 'service_url': getattr(plugin, 'service_url', None), - 'setup_url': getattr(plugin, 'setup_url', None), - 'protocols': protocols, - 'secure_protocols': secure_protocols, - 'details': details, - }) + response['schemas'].append(content) return response - def urls(self): + def urls(self, privacy=False): """ Returns all of the loaded URLs defined in this apprise object. """ - return [x.url() for x in self.servers] + return [x.url(privacy=privacy) for x in self.servers] def pop(self, index): """ @@ -592,3 +791,7 @@ class Apprise(object): """ return sum([1 if not isinstance(s, (ConfigBase, AppriseConfig)) else len(s.servers()) for s in self.servers]) + + +if six.PY2: + del Apprise.async_notify diff --git a/libs/apprise/Apprise.pyi b/libs/apprise/Apprise.pyi new file mode 100644 index 000000000..919d370db --- /dev/null +++ b/libs/apprise/Apprise.pyi @@ -0,0 +1,63 @@ +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 __nonzero__(self) -> bool: ... + def __iter__(self) -> Iterator[ConfigBase]: ... + def __len__(self) -> int: ...
\ No newline at end of file diff --git a/libs/apprise/AppriseAsset.py b/libs/apprise/AppriseAsset.py index 123da7225..e2e95b4a7 100644 --- a/libs/apprise/AppriseAsset.py +++ b/libs/apprise/AppriseAsset.py @@ -24,7 +24,7 @@ # THE SOFTWARE. import re - +from uuid import uuid4 from os.path import join from os.path import dirname from os.path import isfile @@ -105,6 +105,36 @@ class AppriseAsset(object): # 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 + + # 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 + + # 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, **kwargs): """ Asset Initialization diff --git a/libs/apprise/AppriseAsset.pyi b/libs/apprise/AppriseAsset.pyi new file mode 100644 index 000000000..08303341b --- /dev/null +++ b/libs/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/libs/apprise/AppriseAttachment.py b/libs/apprise/AppriseAttachment.py index a8f27e179..37d2c0901 100644 --- a/libs/apprise/AppriseAttachment.py +++ b/libs/apprise/AppriseAttachment.py @@ -29,6 +29,8 @@ from . import attachment from . import URLBase from .AppriseAsset import AppriseAsset from .logger import logger +from .common import ContentLocation +from .common import CONTENT_LOCATIONS from .utils import GET_SCHEMA_RE @@ -38,7 +40,8 @@ class AppriseAttachment(object): """ - def __init__(self, paths=None, asset=None, cache=True, **kwargs): + def __init__(self, paths=None, asset=None, cache=True, location=None, + **kwargs): """ Loads all of the paths/urls specified (if any). @@ -59,6 +62,25 @@ class AppriseAttachment(object): 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 @@ -71,6 +93,15 @@ class AppriseAttachment(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) @@ -123,26 +154,45 @@ class AppriseAttachment(object): # Iterate over our attachments for _attachment in attachments: - - if isinstance(_attachment, attachment.AttachBase): - # Go ahead and just add our attachment into our list - self.attachments.append(_attachment) + if self.location == ContentLocation.INACCESSIBLE: + logger.warning( + "Attachments are disabled; ignoring {}" + .format(_attachment)) + return_status = False continue - elif not isinstance(_attachment, six.string_types): + if isinstance(_attachment, six.string_types): + 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 not isinstance(_attachment, attachment.AttachBase): logger.warning( "An invalid attachment (type={}) was specified.".format( type(_attachment))) return_status = False continue - logger.debug("Loading attachment: {}".format(_attachment)) + else: + # our entry is of type AttachBase, so just go ahead and point + # our instance to it for some post processing below + instance = _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): + # 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 diff --git a/libs/apprise/AppriseAttachment.pyi b/libs/apprise/AppriseAttachment.pyi new file mode 100644 index 000000000..d68eccc13 --- /dev/null +++ b/libs/apprise/AppriseAttachment.pyi @@ -0,0 +1,38 @@ +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 __nonzero__(self) -> bool: ... + def __iter__(self) -> Iterator[AttachBase]: ... + def __len__(self) -> int: ...
\ No newline at end of file diff --git a/libs/apprise/AppriseConfig.pyi b/libs/apprise/AppriseConfig.pyi new file mode 100644 index 000000000..36fa9c065 --- /dev/null +++ b/libs/apprise/AppriseConfig.pyi @@ -0,0 +1,49 @@ +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 __nonzero__(self) -> bool: ... + def __iter__(self) -> Iterator[ConfigBase]: ... + def __len__(self) -> int: ...
\ No newline at end of file diff --git a/libs/apprise/URLBase.py b/libs/apprise/URLBase.py index 78109ae48..f5428dbb1 100644 --- a/libs/apprise/URLBase.py +++ b/libs/apprise/URLBase.py @@ -25,7 +25,7 @@ import re import six -import logging +from .logger import logger from time import sleep from datetime import datetime from xml.sax.saxutils import escape as sax_escape @@ -47,6 +47,7 @@ from .AppriseAsset import AppriseAsset 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,\\/]+') @@ -115,8 +116,8 @@ class URLBase(object): # Secure sites should be verified against a Certificate Authority verify_certificate = True - # Logging - logger = logging.getLogger(__name__) + # 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. @@ -280,7 +281,7 @@ class URLBase(object): self._last_io_datetime = reference return - if self.request_rate_per_sec <= 0.0: + if self.request_rate_per_sec <= 0.0 and not wait: # We're done if there is no throttle limit set return @@ -560,6 +561,39 @@ class URLBase(object): 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 [] + + except AttributeError: + # This exception ONLY gets thrown under Python v2.7 if an + # object() is passed in place of the content + return [] + + content = parse_phone_no(content) + + return content + @property def app_id(self): return self.asset.app_id if self.asset.app_id else '' @@ -636,6 +670,8 @@ class URLBase(object): 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'] diff --git a/libs/apprise/URLBase.pyi b/libs/apprise/URLBase.pyi new file mode 100644 index 000000000..915885745 --- /dev/null +++ b/libs/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/libs/apprise/__init__.py b/libs/apprise/__init__.py index a2511d286..090261086 100644 --- a/libs/apprise/__init__.py +++ b/libs/apprise/__init__.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # -# Copyright (C) 2020 Chris Caron <[email protected]> +# Copyright (C) 2021 Chris Caron <[email protected]> # All rights reserved. # # This code is licensed under the MIT License. @@ -23,11 +23,11 @@ # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN # THE SOFTWARE. -__title__ = 'apprise' -__version__ = '0.8.8' +__title__ = 'Apprise' +__version__ = '0.9.6' __author__ = 'Chris Caron' __license__ = 'MIT' -__copywrite__ = 'Copyright (C) 2020 Chris Caron <[email protected]>' +__copywrite__ = 'Copyright (C) 2021 Chris Caron <[email protected]>' __email__ = '[email protected]' __status__ = 'Production' @@ -41,8 +41,10 @@ from .common import OverflowMode from .common import OVERFLOW_MODES from .common import ConfigFormat from .common import CONFIG_FORMATS -from .common import ConfigIncludeMode -from .common import CONFIG_INCLUDE_MODES +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 @@ -55,10 +57,13 @@ from .AppriseAsset import AppriseAsset from .AppriseConfig import AppriseConfig from .AppriseAttachment import AppriseAttachment +# 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. -import logging -from logging import NullHandler -logging.getLogger(__name__).addHandler(NullHandler()) +logging.getLogger(__name__).addHandler(logging.NullHandler()) __all__ = [ # Core @@ -69,6 +74,10 @@ __all__ = [ 'NotifyType', 'NotifyImageSize', 'NotifyFormat', 'OverflowMode', 'NOTIFY_TYPES', 'NOTIFY_IMAGE_SIZES', 'NOTIFY_FORMATS', 'OVERFLOW_MODES', 'ConfigFormat', 'CONFIG_FORMATS', - 'ConfigIncludeMode', 'CONFIG_INCLUDE_MODES', + 'ContentIncludeMode', 'CONTENT_INCLUDE_MODES', + 'ContentLocation', 'CONTENT_LOCATIONS', 'PrivacyMode', + + # Logging + 'logging', 'logger', 'LogCapture', ] diff --git a/libs/apprise/assets/NotifyXML-1.0.xsd b/libs/apprise/assets/NotifyXML-1.0.xsd index d9b7235aa..0e3f8f130 100644 --- a/libs/apprise/assets/NotifyXML-1.0.xsd +++ b/libs/apprise/assets/NotifyXML-1.0.xsd @@ -1,22 +1,23 @@ <?xml version="1.0" encoding="utf-8"?> -<xs:schema elementFormDefault="qualified" xmlns:xs="http://www.w3.org/2001/XMLSchema"> +<xs:schema attributeFormDefault="unqualified" elementFormDefault="qualified" xmlns:xs="http://www.w3.org/2001/XMLSchema"> + <xs:import namespace="http://schemas.xmlsoap.org/soap/envelope/" schemaLocation="http://schemas.xmlsoap.org/soap/envelope/"/> <xs:element name="Notification"> - <xs:complexType> - <xs:sequence> - <xs:element name="Version" type="xs:string" /> - <xs:element name="MessageType" type="xs:string" /> - <xs:simpleType> - <xs:restriction base="xs:string"> - <xs:enumeration value="success" /> - <xs:enumeration value="failure" /> - <xs:enumeration value="info" /> - <xs:enumeration value="warning" /> - </xs:restriction> - </xs:simpleType> - </xs:element> - <xs:element name="Subject" type="xs:string" /> - <xs:element name="Message" type="xs:string" /> - </xs:sequence> - </xs:complexType> - </xs:element> + <xs:complexType> + <xs:sequence> + <xs:element name="Version" type="xs:string" /> + <xs:element name="Subject" type="xs:string" /> + <xs:element name="MessageType"> + <xs:simpleType> + <xs:restriction base="xs:string"> + <xs:enumeration value="success" /> + <xs:enumeration value="failure" /> + <xs:enumeration value="info" /> + <xs:enumeration value="warning" /> + </xs:restriction> + </xs:simpleType> + </xs:element> + <xs:element name="Message" type="xs:string" /> + </xs:sequence> + </xs:complexType> + </xs:element> </xs:schema> diff --git a/libs/apprise/assets/NotifyXML-1.1.xsd b/libs/apprise/assets/NotifyXML-1.1.xsd new file mode 100644 index 000000000..cc6dbae65 --- /dev/null +++ b/libs/apprise/assets/NotifyXML-1.1.xsd @@ -0,0 +1,40 @@ +<?xml version="1.0" encoding="utf-8"?> +<xs:schema attributeFormDefault="unqualified" elementFormDefault="qualified" xmlns:xs="http://www.w3.org/2001/XMLSchema"> + <xs:import namespace="http://schemas.xmlsoap.org/soap/envelope/" schemaLocation="http://schemas.xmlsoap.org/soap/envelope/"/> + <xs:element name="Notification"> + <xs:complexType> + <xs:sequence> + <xs:element name="Version" type="xs:string" /> + <xs:element name="Subject" type="xs:string" /> + <xs:element name="MessageType"> + <xs:simpleType> + <xs:restriction base="xs:string"> + <xs:enumeration value="success" /> + <xs:enumeration value="failure" /> + <xs:enumeration value="info" /> + <xs:enumeration value="warning" /> + </xs:restriction> + </xs:simpleType> + </xs:element> + <xs:element name="Message" type="xs:string" /> + <xs:element name="Attachments" minOccurs="0"> + <xs:complexType> + <xs:sequence> + <xs:element name="Attachment" minOccurs="0" maxOccurs="unbounded"> + <xs:complexType> + <xs:simpleContent> + <xs:extension base="xs:string"> + <xs:attribute name="mimetype" type="xs:string" use="required"/> + <xs:attribute name="filename" type="xs:string" use="required"/> + </xs:extension> + </xs:simpleContent> + </xs:complexType> + </xs:element> + </xs:sequence> + <xs:attribute name="encoding" type="xs:string" use="required"/> + </xs:complexType> + </xs:element> + </xs:sequence> + </xs:complexType> + </xs:element> +</xs:schema> diff --git a/libs/apprise/attachment/AttachBase.py b/libs/apprise/attachment/AttachBase.py index 1fde66f4b..aa7174fcf 100644 --- a/libs/apprise/attachment/AttachBase.py +++ b/libs/apprise/attachment/AttachBase.py @@ -28,6 +28,7 @@ import time import mimetypes from ..URLBase import URLBase from ..utils import parse_bool +from ..common import ContentLocation from ..AppriseLocale import gettext_lazy as _ @@ -62,6 +63,11 @@ class AttachBase(URLBase): # 5 MB = 5242880 bytes max_file_size = 5242880 + # 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 diff --git a/libs/apprise/attachment/AttachBase.pyi b/libs/apprise/attachment/AttachBase.pyi new file mode 100644 index 000000000..9b8eb02a5 --- /dev/null +++ b/libs/apprise/attachment/AttachBase.pyi @@ -0,0 +1,37 @@ +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: ... + def __nonzero__(self) -> bool: ...
\ No newline at end of file diff --git a/libs/apprise/attachment/AttachFile.py b/libs/apprise/attachment/AttachFile.py index a8609bd60..20ee15199 100644 --- a/libs/apprise/attachment/AttachFile.py +++ b/libs/apprise/attachment/AttachFile.py @@ -26,6 +26,7 @@ import re import os from .AttachBase import AttachBase +from ..common import ContentLocation from ..AppriseLocale import gettext_lazy as _ @@ -40,6 +41,10 @@ class AttachFile(AttachBase): # 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 @@ -81,6 +86,10 @@ class AttachFile(AttachBase): validate it. """ + if self.location == ContentLocation.INACCESSIBLE: + # our content is inaccessible + return False + # Ensure any existing content set has been invalidated self.invalidate() diff --git a/libs/apprise/attachment/AttachHTTP.py b/libs/apprise/attachment/AttachHTTP.py index d5396cf85..1d915ad3c 100644 --- a/libs/apprise/attachment/AttachHTTP.py +++ b/libs/apprise/attachment/AttachHTTP.py @@ -29,6 +29,7 @@ import six import requests from tempfile import NamedTemporaryFile from .AttachBase import AttachBase +from ..common import ContentLocation from ..URLBase import PrivacyMode from ..AppriseLocale import gettext_lazy as _ @@ -50,6 +51,9 @@ class AttachHTTP(AttachBase): # 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 @@ -86,6 +90,10 @@ class AttachHTTP(AttachBase): 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() diff --git a/libs/apprise/cli.py b/libs/apprise/cli.py index 690530000..70458c92d 100644 --- a/libs/apprise/cli.py +++ b/libs/apprise/cli.py @@ -26,7 +26,11 @@ import click import logging import platform +import six import sys +import os +import re + from os.path import isfile from os.path import expanduser from os.path import expandvars @@ -39,6 +43,7 @@ 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__ @@ -133,6 +138,9 @@ def print_version_msg(): help='Perform a trial run but only prints the notification ' 'services to-be triggered to stdout. Notifications are never ' 'sent using this mode.') [email protected]('--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 ' @@ -141,6 +149,8 @@ def print_version_msg(): @click.option('--verbose', '-v', count=True, help='Makes the operation more talkative. Use multiple v to ' 'increase the verbosity. I.e.: -vvvv') [email protected]('--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.') @@ -148,7 +158,7 @@ def print_version_msg(): 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, - debug, version): + details, interpret_escapes, 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. @@ -224,8 +234,15 @@ def main(body, title, config, attach, urls, notification_type, theme, tag, # 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 is only used for Python v3+ and allows a user to send # all of their notifications asyncronously. This was made an option # incase there are problems in the future where it's better that @@ -234,18 +251,132 @@ def main(body, title, config, attach, urls, notification_type, theme, tag, ) # Create our Apprise object - a = Apprise(asset=asset, debug=debug) - - # Load our configuration if no URLs or specified configuration was - # identified on the command line - a.add(AppriseConfig( - paths=[f for f in DEFAULT_SEARCH_PATHS if isfile(expanduser(f))] - if not (config or urls) else config, - asset=asset, recursion=recursion_depth)) - - # Load our inventory up - for url in urls: - a.add(url) + 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, six.string_types)] + protocols.extend( + [] if not entry['secure_protocols'] else + [p for p in entry['secure_protocols'] + if isinstance(p, six.string_types)]) + + 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]) + + click.echo(click.style( + '{} {:<30} '.format( + '+' if entry['enabled'] else '-', + str(entry['service_name'])), + fg="green" if entry['enabled'] else "red", 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 + 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) + + # 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_SEARCH_PATHS if isfile(expanduser(f))], + asset=asset, recursion=recursion_depth)) if len(a) == 0: logger.error( diff --git a/libs/apprise/common.py b/libs/apprise/common.py index 329b5d93f..186bfe1bc 100644 --- a/libs/apprise/common.py +++ b/libs/apprise/common.py @@ -130,28 +130,58 @@ CONFIG_FORMATS = ( ) -class ConfigIncludeMode(object): +class ContentIncludeMode(object): """ - The different Cofiguration inclusion modes. All Configuration - plugins will have one of these associated with it. + The different Content inclusion modes. All content based plugins will + have one of these associated with it. """ - # - Configuration inclusion of same type only; hence a file:// can include + # - 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 configuration type can never be included + # This content type can never be included NEVER = 'never' - # File configuration can always be included + # This content can always be included ALWAYS = 'always' -CONFIG_INCLUDE_MODES = ( - ConfigIncludeMode.STRICT, - ConfigIncludeMode.NEVER, - ConfigIncludeMode.ALWAYS, +CONTENT_INCLUDE_MODES = ( + ContentIncludeMode.STRICT, + ContentIncludeMode.NEVER, + ContentIncludeMode.ALWAYS, +) + + +class ContentLocation(object): + """ + 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 diff --git a/libs/apprise/common.pyi b/libs/apprise/common.pyi new file mode 100644 index 000000000..769573185 --- /dev/null +++ b/libs/apprise/common.pyi @@ -0,0 +1,15 @@ +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
\ No newline at end of file diff --git a/libs/apprise/config/ConfigBase.py b/libs/apprise/config/ConfigBase.py index 22efd8e29..f2b958ed8 100644 --- a/libs/apprise/config/ConfigBase.py +++ b/libs/apprise/config/ConfigBase.py @@ -34,13 +34,18 @@ from ..AppriseAsset import AppriseAsset from ..URLBase import URLBase from ..common import ConfigFormat from ..common import CONFIG_FORMATS -from ..common import ConfigIncludeMode +from ..common import ContentIncludeMode 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 from . import SCHEMA_MAP +# Test whether token is valid or not +VALID_TOKEN = re.compile( + r'(?P<token>[a-z0-9][a-z0-9_]+)', re.I) + class ConfigBase(URLBase): """ @@ -65,7 +70,7 @@ class ConfigBase(URLBase): # By default all configuration is not includable using the 'include' # line found in configuration files. - allow_cross_includes = ConfigIncludeMode.NEVER + allow_cross_includes = ContentIncludeMode.NEVER # the config path manages the handling of relative include config_path = os.getcwd() @@ -205,8 +210,8 @@ class ConfigBase(URLBase): # Configuration files were detected; recursively populate them # If we have been configured to do so for url in configs: - if self.recursion > 0: + 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) @@ -219,6 +224,7 @@ class ConfigBase(URLBase): 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() @@ -229,27 +235,31 @@ class ConfigBase(URLBase): '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 = SCHEMA_MAP[schema].parse_url(url) if not results: # Failed to parse the server URL self.logger.warning( - 'Unparseable include URL {}'.format(url)) + 'Unparseable include URL {}'.format(loggable_url)) continue # Handle cross inclusion based on allow_cross_includes rules if (SCHEMA_MAP[schema].allow_cross_includes == - ConfigIncludeMode.STRICT + ContentIncludeMode.STRICT and schema not in self.schemas() and not self.insecure_includes) or \ SCHEMA_MAP[schema].allow_cross_includes == \ - ConfigIncludeMode.NEVER: + ContentIncludeMode.NEVER: # Prevent the loading if insecure base protocols ConfigBase.logger.warning( 'Including {}:// based configuration is prohibited. ' - 'Ignoring URL {}'.format(schema, url)) + 'Ignoring URL {}'.format(schema, loggable_url)) continue # Prepare our Asset Object @@ -275,7 +285,7 @@ class ConfigBase(URLBase): except Exception as e: # the arguments are invalid or can not be used. self.logger.warning( - 'Could not load include URL: {}'.format(url)) + 'Could not load include URL: {}'.format(loggable_url)) self.logger.debug('Loading Exception: {}'.format(str(e))) continue @@ -288,16 +298,23 @@ class ConfigBase(URLBase): 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' % url) + '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())) + 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())) + self.url(privacy=asset.secure_logging))) # Set the time our content was cached at self._cached_time = time.time() @@ -527,6 +544,9 @@ class ConfigBase(URLBase): # the include keyword configs = list() + # 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<line>([;#]+(?P<comment>.*))|' @@ -563,27 +583,37 @@ class ConfigBase(URLBase): continue if config: - ConfigBase.logger.debug('Include URL: {}'.format(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) + # Acquire our url tokens - results = plugins.url_to_dict(url) + 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(url, line)) + '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'))) - # Prepare our Asset Object - results['asset'] = \ - asset if isinstance(asset, AppriseAsset) else AppriseAsset() + # Set our Asset Object + results['asset'] = asset try: # Attempt to create an instance of our plugin using the @@ -591,13 +621,14 @@ class ConfigBase(URLBase): plugin = plugins.SCHEMA_MAP[results['schema']](**results) # Create log entry of loaded URL - ConfigBase.logger.debug('Loaded URL: {}'.format(plugin.url())) + ConfigBase.logger.debug( + 'Loaded URL: %s', plugin.url(privacy=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( - url, line)) + loggable_url, line)) ConfigBase.logger.debug('Loading Exception: %s' % str(e)) continue @@ -633,7 +664,9 @@ class ConfigBase(URLBase): # Load our data (safely) result = yaml.load(content, Loader=yaml.SafeLoader) - except (AttributeError, yaml.error.MarkedYAMLError) as e: + except (AttributeError, + yaml.parser.ParserError, + yaml.error.MarkedYAMLError) as e: # Invalid content ConfigBase.logger.error( 'Invalid Apprise YAML data specified.') @@ -671,7 +704,9 @@ class ConfigBase(URLBase): continue if not (hasattr(asset, k) and - isinstance(getattr(asset, k), six.string_types)): + isinstance(getattr(asset, k), + (bool, six.string_types))): + # We can't set a function or non-string set value ConfigBase.logger.warning( 'Invalid asset key "{}".'.format(k)) @@ -681,15 +716,23 @@ class ConfigBase(URLBase): # Convert to an empty string v = '' - if not isinstance(v, six.string_types): + if (isinstance(v, (bool, six.string_types)) + 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, six.string_types): + # 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 - - # Set our asset object with the new value - setattr(asset, k, v.strip()) - # # global tag root directive # @@ -740,6 +783,10 @@ class ConfigBase(URLBase): # 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, six.string_types): # We're just a simple URL string... schema = GET_SCHEMA_RE.match(url) @@ -748,16 +795,18 @@ class ConfigBase(URLBase): # config file at least has something to take action # with. ConfigBase.logger.warning( - 'Invalid URL {}, entry #{}'.format(url, no + 1)) + '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) + _results = plugins.url_to_dict( + url, secure_logging=asset.secure_logging) if _results is None: ConfigBase.logger.warning( 'Unparseable URL {}, entry #{}'.format( - url, no + 1)) + loggable_url, no + 1)) continue # add our results to our global set @@ -791,19 +840,20 @@ class ConfigBase(URLBase): .format(key, no + 1)) continue - # Store our URL and Schema Regex - _url = key - # 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 schema in urls, entry #{}'.format(no + 1)) + 'Unsupported URL, entry #{}'.format(no + 1)) continue - _results = plugins.url_to_dict(_url) + _results = plugins.url_to_dict( + _url, secure_logging=asset.secure_logging) if _results is None: # Setup dictionary _results = { @@ -830,12 +880,33 @@ class ConfigBase(URLBase): if 'schema' in entries: del entries['schema'] + # support our special tokens (if they're present) + if schema in plugins.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 plugins.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) @@ -867,6 +938,17 @@ class ConfigBase(URLBase): # 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( @@ -883,7 +965,8 @@ class ConfigBase(URLBase): # Create log entry of loaded URL ConfigBase.logger.debug( - 'Loaded URL: {}'.format(plugin.url())) + 'Loaded URL: {}'.format( + plugin.url(privacy=asset.secure_logging))) except Exception as e: # the arguments are invalid or can not be used. @@ -913,6 +996,135 @@ class ConfigBase(URLBase): # 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 plugins.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: [email protected] + # - to: [email protected] + # + # 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(plugins.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|choice):.*', + 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, six.string_types): + + # 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 diff --git a/libs/apprise/config/ConfigBase.pyi b/libs/apprise/config/ConfigBase.pyi new file mode 100644 index 000000000..abff1204d --- /dev/null +++ b/libs/apprise/config/ConfigBase.pyi @@ -0,0 +1,3 @@ +from .. import URLBase + +class ConfigBase(URLBase): ...
\ No newline at end of file diff --git a/libs/apprise/config/ConfigFile.py b/libs/apprise/config/ConfigFile.py index 9f8102253..6fd1ecb23 100644 --- a/libs/apprise/config/ConfigFile.py +++ b/libs/apprise/config/ConfigFile.py @@ -28,7 +28,7 @@ import io import os from .ConfigBase import ConfigBase from ..common import ConfigFormat -from ..common import ConfigIncludeMode +from ..common import ContentIncludeMode from ..AppriseLocale import gettext_lazy as _ @@ -44,7 +44,7 @@ class ConfigFile(ConfigBase): protocol = 'file' # Configuration file inclusion can only be of the same type - allow_cross_includes = ConfigIncludeMode.STRICT + allow_cross_includes = ContentIncludeMode.STRICT def __init__(self, path, **kwargs): """ diff --git a/libs/apprise/config/ConfigHTTP.py b/libs/apprise/config/ConfigHTTP.py index c4ad29425..88352733c 100644 --- a/libs/apprise/config/ConfigHTTP.py +++ b/libs/apprise/config/ConfigHTTP.py @@ -28,7 +28,7 @@ import six import requests from .ConfigBase import ConfigBase from ..common import ConfigFormat -from ..common import ConfigIncludeMode +from ..common import ContentIncludeMode from ..URLBase import PrivacyMode from ..AppriseLocale import gettext_lazy as _ @@ -66,7 +66,7 @@ class ConfigHTTP(ConfigBase): max_error_buffer_size = 2048 # Configuration file inclusion can always include this type - allow_cross_includes = ConfigIncludeMode.ALWAYS + allow_cross_includes = ContentIncludeMode.ALWAYS def __init__(self, headers=None, **kwargs): """ diff --git a/libs/apprise/i18n/apprise.pot b/libs/apprise/i18n/apprise.pot index 2a0dc5e0a..274b379c1 100644 --- a/libs/apprise/i18n/apprise.pot +++ b/libs/apprise/i18n/apprise.pot @@ -1,21 +1,27 @@ # Translations template for apprise. -# Copyright (C) 2020 Chris Caron +# Copyright (C) 2021 Chris Caron # This file is distributed under the same license as the apprise project. -# FIRST AUTHOR <EMAIL@ADDRESS>, 2020. +# FIRST AUTHOR <EMAIL@ADDRESS>, 2021. # #, fuzzy msgid "" msgstr "" -"Project-Id-Version: apprise 0.8.8\n" +"Project-Id-Version: apprise 0.9.6\n" "Report-Msgid-Bugs-To: [email protected]\n" -"POT-Creation-Date: 2020-09-02 07:46-0400\n" +"POT-Creation-Date: 2021-12-01 18:56-0500\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME <EMAIL@ADDRESS>\n" "Language-Team: LANGUAGE <[email protected]>\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" -"Generated-By: Babel 2.7.0\n" +"Generated-By: Babel 2.9.1\n" + +msgid "A local Gnome environment is required." +msgstr "" + +msgid "A local Microsoft Windows environment is required." +msgstr "" msgid "API Key" msgstr "" @@ -44,6 +50,27 @@ msgstr "" msgid "Add Tokens" msgstr "" +msgid "Alert Type" +msgstr "" + +msgid "Alias" +msgstr "" + +msgid "Amount" +msgstr "" + +msgid "App Access Token" +msgstr "" + +msgid "App ID" +msgstr "" + +msgid "App Version" +msgstr "" + +msgid "Application ID" +msgstr "" + msgid "Application Key" msgstr "" @@ -83,6 +110,9 @@ msgstr "" msgid "Cache Results" msgstr "" +msgid "Call" +msgstr "" + msgid "Carbon Copy" msgstr "" @@ -104,15 +134,27 @@ msgstr "" msgid "Country" msgstr "" +msgid "Currency" +msgstr "" + msgid "Custom Icon" msgstr "" msgid "Cycles" msgstr "" +msgid "DBus Notification" +msgstr "" + +msgid "Details" +msgstr "" + msgid "Detect Bot Owner" msgstr "" +msgid "Device" +msgstr "" + msgid "Device API Key" msgstr "" @@ -134,12 +176,18 @@ msgstr "" msgid "Email" msgstr "" +msgid "Email Header" +msgstr "" + msgid "Encrypted Password" msgstr "" msgid "Encrypted Salt" msgstr "" +msgid "Entity" +msgstr "" + msgid "Event" msgstr "" @@ -152,6 +200,12 @@ msgstr "" msgid "Facility" msgstr "" +msgid "Flair ID" +msgstr "" + +msgid "Flair Text" +msgstr "" + msgid "Footer Logo" msgstr "" @@ -170,6 +224,9 @@ msgstr "" msgid "From Phone No" msgstr "" +msgid "Gnome Notification" +msgstr "" + msgid "Group" msgstr "" @@ -185,12 +242,33 @@ msgstr "" msgid "Icon Type" msgstr "" +msgid "Identifier" +msgstr "" + +msgid "Image Link" +msgstr "" + msgid "Include Footer" msgstr "" msgid "Include Image" msgstr "" +msgid "Include Segment" +msgstr "" + +msgid "Is Ad?" +msgstr "" + +msgid "Is Spoiler" +msgstr "" + +msgid "Kind" +msgstr "" + +msgid "Language" +msgstr "" + msgid "Local File" msgstr "" @@ -200,6 +278,15 @@ msgstr "" msgid "Log to STDERR" msgstr "" +msgid "Long-Lived Access Token" +msgstr "" + +msgid "MacOSX Notification" +msgstr "" + +msgid "Master Key" +msgstr "" + msgid "Memory" msgstr "" @@ -209,18 +296,41 @@ msgstr "" msgid "Message Mode" msgstr "" +msgid "Message Type" +msgstr "" + msgid "Modal" msgstr "" msgid "Mode" msgstr "" +msgid "NSFW" +msgstr "" + +msgid "Name" +msgstr "" + +msgid "No dependencies." +msgstr "" + +msgid "Notification ID" +msgstr "" + msgid "Notify Format" msgstr "" msgid "OAuth Access Token" msgstr "" +msgid "OAuth2 KeyFile" +msgstr "" + +msgid "" +"Only works with Mac OS X 10.8 and higher. Additionally requires that " +"/usr/local/bin/terminal-notifier is locally accessible." +msgstr "" + msgid "Organization" msgstr "" @@ -230,6 +340,12 @@ msgstr "" msgid "Overflow Mode" msgstr "" +msgid "Packages are recommended to improve functionality." +msgstr "" + +msgid "Packages are required to function." +msgstr "" + msgid "Password" msgstr "" @@ -254,6 +370,9 @@ msgstr "" msgid "Provider Key" msgstr "" +msgid "QOS" +msgstr "" + msgid "Region" msgstr "" @@ -263,6 +382,9 @@ msgstr "" msgid "Remove Tokens" msgstr "" +msgid "Resubmit Flag" +msgstr "" + msgid "Retry" msgstr "" @@ -287,6 +409,9 @@ msgstr "" msgid "Secure Mode" msgstr "" +msgid "Send Replies" +msgstr "" + msgid "Sender ID" msgstr "" @@ -296,6 +421,9 @@ msgstr "" msgid "Server Timeout" msgstr "" +msgid "Silent Notification" +msgstr "" + msgid "Socket Connect Timeout" msgstr "" @@ -305,6 +433,9 @@ msgstr "" msgid "Sound" msgstr "" +msgid "Sound Link" +msgstr "" + msgid "Source Email" msgstr "" @@ -314,12 +445,21 @@ msgstr "" msgid "Source Phone No" msgstr "" +msgid "Special Text Color" +msgstr "" + msgid "Sticky" msgstr "" msgid "Subtitle" msgstr "" +msgid "Syslog Mode" +msgstr "" + +msgid "Tags" +msgstr "" + msgid "Target Channel" msgstr "" @@ -344,24 +484,45 @@ msgstr "" msgid "Target Encoded ID" msgstr "" +msgid "Target Escalation" +msgstr "" + msgid "Target JID" msgstr "" msgid "Target Phone No" msgstr "" +msgid "Target Player ID" +msgstr "" + +msgid "Target Queue" +msgstr "" + msgid "Target Room Alias" msgstr "" msgid "Target Room ID" msgstr "" +msgid "Target Schedule" +msgstr "" + msgid "Target Short Code" msgstr "" +msgid "Target Stream" +msgstr "" + +msgid "Target Subreddit" +msgstr "" + msgid "Target Tag ID" msgstr "" +msgid "Target Team" +msgstr "" + msgid "Target Topic" msgstr "" @@ -371,12 +532,24 @@ msgstr "" msgid "Targets" msgstr "" +msgid "Targets " +msgstr "" + +msgid "Team Name" +msgstr "" + msgid "Template" msgstr "" msgid "Template Data" msgstr "" +msgid "Template Path" +msgstr "" + +msgid "Template Tokens" +msgstr "" + msgid "Tenant Domain" msgstr "" @@ -404,12 +577,27 @@ msgstr "" msgid "Token C" msgstr "" +msgid "URL" +msgstr "" + +msgid "URL Title" +msgstr "" + msgid "Urgency" msgstr "" msgid "Use Avatar" msgstr "" +msgid "Use Blocks" +msgstr "" + +msgid "Use Fields" +msgstr "" + +msgid "Use Session" +msgstr "" + msgid "User ID" msgstr "" @@ -434,18 +622,27 @@ msgstr "" msgid "Web Based" msgstr "" +msgid "Web Page Preview" +msgstr "" + msgid "Webhook" msgstr "" msgid "Webhook ID" msgstr "" +msgid "Webhook Key" +msgstr "" + msgid "Webhook Mode" msgstr "" msgid "Webhook Token" msgstr "" +msgid "Workspace" +msgstr "" + msgid "X-Axis" msgstr "" @@ -455,6 +652,9 @@ msgstr "" msgid "Y-Axis" msgstr "" +msgid "libdbus-1.so.x must be installed." +msgstr "" + msgid "ttl" msgstr "" diff --git a/libs/apprise/logger.py b/libs/apprise/logger.py index c09027dff..082178129 100644 --- a/libs/apprise/logger.py +++ b/libs/apprise/logger.py @@ -23,7 +23,12 @@ # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN # THE SOFTWARE. +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 @@ -57,5 +62,136 @@ def deprecate(self, message, *args, **kwargs): logging.Logger.trace = trace logging.Logger.deprecate = deprecate -# Create ourselve a generic logging reference -logger = logging.getLogger('apprise') +# Create ourselve a generic (singleton) logging reference +logger = logging.getLogger(LOGGER_NAME) + + +class LogCapture(object): + """ + 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() + 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/libs/apprise/plugins/NotifyAppriseAPI.py b/libs/apprise/plugins/NotifyAppriseAPI.py new file mode 100644 index 000000000..b981f97a2 --- /dev/null +++ b/libs/apprise/plugins/NotifyAppriseAPI.py @@ -0,0 +1,382 @@ +# -*- coding: utf-8 -*- +# +# Copyright (C) 2021 Chris Caron <[email protected]> +# All rights reserved. +# +# This code is licensed under the MIT License. +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files(the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and / or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions : +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. + +import re +import six +import requests +from json import dumps + +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 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' + + # Depending on the number of transactions/notifications taking place, this + # could take a while. 30 seconds should be enough to perform the task + socket_connect_timeout = 30.0 + + # 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', + }, + 'to': { + 'alias_of': 'token', + }, + }) + + # Define any kwargs we're using + template_kwargs = { + 'headers': { + 'name': _('HTTP Header'), + 'prefix': '+', + }, + } + + def __init__(self, token=None, tags=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(NotifyAppriseAPI, self).__init__(**kwargs) + + self.fullpath = kwargs.get('fullpath') + if not isinstance(self.fullpath, six.string_types): + self.fullpath = '/' + + 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) + + # 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. + """ + + # Our URL parameters + params = self.url_parameters(privacy=privacy, *args, **kwargs) + + # Append our headers into our parameters + params.update({'+{}'.format(k): v for k, v in self.headers.items()}) + + if self.__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, **kwargs): + """ + Perform Apprise API Notification + """ + + headers = {} + # Apply any/all header over-rides defined + headers.update(self.headers) + + # prepare Apprise API Object + payload = { + # Apprise API Payload + 'title': title, + 'body': body, + 'type': notify_type, + 'format': self.notify_format, + } + + 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({ + 'User-Agent': self.app_id, + 'Content-Type': '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=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 = \ + 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.') + + 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 + + return True + + @staticmethod + def parse_native_url(url): + """ + Support http://hostname/notify/token and + http://hostname/path/notify/token + """ + + result = re.match( + r'^http(?P<secure>s?)://(?P<hostname>[A-Z0-9._-]+)' + r'(:(?P<port>[0-9]+))?' + r'(?P<path>/[^?]+?)?/notify/(?P<token>[A-Z0-9_-]{1,32})/?' + r'(?P<params>\?.+)?$', 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 + results['headers'] = results['qsd+'] + if results['qsd-']: + results['headers'].update(results['qsd-']) + NotifyBase.logger.deprecate( + "minus (-) based Apprise API header tokens are being " + " removed; use the plus (+) symbol instead.") + + # Tidy our header entries by unquoting them + results['headers'] = \ + {NotifyAppriseAPI.unquote(x): NotifyAppriseAPI.unquote(y) + for x, y in results['headers'].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) + + return results diff --git a/libs/apprise/plugins/NotifyBase.py b/libs/apprise/plugins/NotifyBase.py index 3a0538bcc..82c025506 100644 --- a/libs/apprise/plugins/NotifyBase.py +++ b/libs/apprise/plugins/NotifyBase.py @@ -52,6 +52,54 @@ class NotifyBase(BASE_OBJECT): 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 + + # 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 @@ -153,7 +201,8 @@ class NotifyBase(BASE_OBJECT): # Provide override self.overflow_mode = overflow - def image_url(self, notify_type, logo=False, extension=None): + def image_url(self, notify_type, logo=False, extension=None, + image_size=None): """ Returns Image URL if possible """ @@ -166,7 +215,7 @@ class NotifyBase(BASE_OBJECT): return self.asset.image_url( notify_type=notify_type, - image_size=self.image_size, + image_size=self.image_size if image_size is None else image_size, logo=logo, extension=extension, ) @@ -222,6 +271,13 @@ class NotifyBase(BASE_OBJECT): """ + if not self.enabled: + # Deny notifications issued to services that are disabled + self.logger.warning( + "{} is currently disabled on this system.".format( + self.service_name)) + return False + # Prepare attachments if required if attach is not None and not isinstance(attach, AppriseAttachment): try: diff --git a/libs/apprise/plugins/NotifyBase.pyi b/libs/apprise/plugins/NotifyBase.pyi new file mode 100644 index 000000000..9cf3e404c --- /dev/null +++ b/libs/apprise/plugins/NotifyBase.pyi @@ -0,0 +1 @@ +class NotifyBase: ...
\ No newline at end of file diff --git a/libs/apprise/plugins/NotifyClickSend.py b/libs/apprise/plugins/NotifyClickSend.py index a7d89c18b..9054c6f01 100644 --- a/libs/apprise/plugins/NotifyClickSend.py +++ b/libs/apprise/plugins/NotifyClickSend.py @@ -36,7 +36,6 @@ # The API reference used to build this plugin was documented here: # https://developers.clicksend.com/docs/rest/v3/ # -import re import requests from json import dumps from base64 import b64encode @@ -44,7 +43,8 @@ from base64 import b64encode from .NotifyBase import NotifyBase from ..URLBase import PrivacyMode from ..common import NotifyType -from ..utils import parse_list +from ..utils import is_phone_no +from ..utils import parse_phone_no from ..utils import parse_bool from ..AppriseLocale import gettext_lazy as _ @@ -53,12 +53,6 @@ CLICKSEND_HTTP_ERROR_MAP = { 401: 'Unauthorized - Invalid Token.', } -# Some Phone Number Detection -IS_PHONE_NO = re.compile(r'^\+?(?P<phone>[0-9\s)(+-]+)\s*$') - -# Used to break path apart into list of channels -TARGET_LIST_DELIM = re.compile(r'[ \t\r\n,#\\/]+') - class NotifyClickSend(NotifyBase): """ @@ -151,26 +145,18 @@ class NotifyClickSend(NotifyBase): self.logger.warning(msg) raise TypeError(msg) - for target in parse_list(targets): + for target in parse_phone_no(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) + result = is_phone_no(target) + if not result: + self.logger.warning( + 'Dropped invalid phone # ' + '({}) specified.'.format(target), + ) continue - self.logger.warning( - 'Dropped invalid phone # ' - '({}) specified.'.format(target)) + # store valid phone number + self.targets.append(result['full']) def send(self, body, title='', notify_type=NotifyType.INFO, **kwargs): """ @@ -321,8 +307,7 @@ class NotifyClickSend(NotifyBase): # Support the 'to' variable so that we can support rooms this way too # The 'to' makes it easier to use yaml configuration if 'to' in results['qsd'] and len(results['qsd']['to']): - results['targets'] += [x for x in filter( - bool, TARGET_LIST_DELIM.split( - NotifyClickSend.unquote(results['qsd']['to'])))] + results['targets'] += \ + NotifyClickSend.parse_phone_no(results['qsd']['to']) return results diff --git a/libs/apprise/plugins/NotifyD7Networks.py b/libs/apprise/plugins/NotifyD7Networks.py index f04082c68..728f119ab 100644 --- a/libs/apprise/plugins/NotifyD7Networks.py +++ b/libs/apprise/plugins/NotifyD7Networks.py @@ -30,7 +30,6 @@ # (both user and password) from the API Details section from within your # account profile area: https://d7networks.com/accounts/profile/ -import re import six import requests import base64 @@ -40,7 +39,8 @@ from json import loads from .NotifyBase import NotifyBase from ..URLBase import PrivacyMode from ..common import NotifyType -from ..utils import parse_list +from ..utils import is_phone_no +from ..utils import parse_phone_no from ..utils import parse_bool from ..AppriseLocale import gettext_lazy as _ @@ -52,9 +52,6 @@ D7NETWORKS_HTTP_ERROR_MAP = { 500: 'A Serverside Error Occured Handling the Request.', } -# Some Phone Number Detection -IS_PHONE_NO = re.compile(r'^\+?(?P<phone>[0-9\s)(+-]+)\s*$') - # Priorities class D7SMSPriority(object): @@ -197,35 +194,25 @@ class NotifyD7Networks(NotifyBase): self.source = None \ if not isinstance(source, six.string_types) else source.strip() + if not (self.user and self.password): + msg = 'A D7 Networks user/pass was not provided.' + self.logger.warning(msg) + raise TypeError(msg) + # Parse our targets self.targets = list() - - for target in parse_list(targets): + for target in parse_phone_no(targets): # Validate targets and drop bad ones: - result = IS_PHONE_NO.match(target) - if result: - # Further check our phone # for it's digit count - # if it's less than 10, then we can assume it's - # a poorly specified phone no and spit a warning - 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) + result = result = is_phone_no(target) + if not result: + self.logger.warning( + 'Dropped invalid phone # ' + '({}) specified.'.format(target), + ) continue - self.logger.warning( - 'Dropped invalid phone # ({}) specified.'.format(target)) - - if len(self.targets) == 0: - msg = 'There are no valid targets identified to notify.' - self.logger.warning(msg) - raise TypeError(msg) + # store valid phone number + self.targets.append(result['full']) return @@ -235,6 +222,11 @@ class NotifyD7Networks(NotifyBase): 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 @@ -479,6 +471,6 @@ class NotifyD7Networks(NotifyBase): # The 'to' makes it easier to use yaml configuration if 'to' in results['qsd'] and len(results['qsd']['to']): results['targets'] += \ - NotifyD7Networks.parse_list(results['qsd']['to']) + NotifyD7Networks.parse_phone_no(results['qsd']['to']) return results diff --git a/libs/apprise/plugins/NotifyDBus.py b/libs/apprise/plugins/NotifyDBus.py index ca501bf9e..145e1c05c 100644 --- a/libs/apprise/plugins/NotifyDBus.py +++ b/libs/apprise/plugins/NotifyDBus.py @@ -38,10 +38,6 @@ NOTIFY_DBUS_SUPPORT_ENABLED = False # Image support is dependant on the GdkPixbuf library being available NOTIFY_DBUS_IMAGE_SUPPORT = False -# The following are required to hook into the notifications: -NOTIFY_DBUS_INTERFACE = 'org.freedesktop.Notifications' -NOTIFY_DBUS_SETTING_LOCATION = '/org/freedesktop/Notifications' - # Initialize our mainloops LOOP_GLIB = None LOOP_QT = None @@ -132,8 +128,19 @@ 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' + 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's it's own dict_keys() @@ -158,14 +165,9 @@ class NotifyDBus(NotifyBase): # content to display body_max_line_count = 10 - # This entry is a bit hacky, but it allows us to unit-test this library - # in an environment that simply doesn't have the gnome packages - # available to us. It also allows us to handle situations where the - # packages actually are present but we need to test that they aren't. - # If anyone is seeing this had knows a better way of testing this - # outside of what is defined in test/test_glib_plugin.py, please - # let me know! :) - _enabled = NOTIFY_DBUS_SUPPORT_ENABLED + # The following are required to hook into the notifications: + dbus_interface = 'org.freedesktop.Notifications' + dbus_setting_location = '/org/freedesktop/Notifications' # Define object templates templates = ( @@ -241,12 +243,6 @@ class NotifyDBus(NotifyBase): """ Perform DBus Notification """ - - if not self._enabled or MAINLOOP_MAP[self.schema] is None: - self.logger.warning( - "{} notifications could not be loaded.".format(self.schema)) - return False - # Acquire our session try: session = SessionBus(mainloop=MAINLOOP_MAP[self.schema]) @@ -265,14 +261,14 @@ class NotifyDBus(NotifyBase): # acquire our dbus object dbus_obj = session.get_object( - NOTIFY_DBUS_INTERFACE, - NOTIFY_DBUS_SETTING_LOCATION, + self.dbus_interface, + self.dbus_setting_location, ) # Acquire our dbus interface dbus_iface = Interface( dbus_obj, - dbus_interface=NOTIFY_DBUS_INTERFACE, + dbus_interface=self.dbus_interface, ) # image path diff --git a/libs/apprise/plugins/NotifyDingTalk.py b/libs/apprise/plugins/NotifyDingTalk.py new file mode 100644 index 000000000..68c069479 --- /dev/null +++ b/libs/apprise/plugins/NotifyDingTalk.py @@ -0,0 +1,343 @@ +# -*- coding: utf-8 -*- +# +# Copyright (C) 2020 Chris Caron <[email protected]> +# All rights reserved. +# +# This code is licensed under the MIT License. +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files(the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and / or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions : +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. + +import 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<phone>[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': _('Token'), + 'type': 'string', + 'private': True, + 'regex': (r'^[a-z0-9]+$', 'i'), + }, + 'targets': { + 'name': _('Target Phone No'), + '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(NotifyDingTalk, self).__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)) + + @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/libs/apprise/plugins/NotifyDiscord.py b/libs/apprise/plugins/NotifyDiscord.py index 8a8b21f44..a4e7df6d4 100644 --- a/libs/apprise/plugins/NotifyDiscord.py +++ b/libs/apprise/plugins/NotifyDiscord.py @@ -80,6 +80,11 @@ class NotifyDiscord(NotifyBase): # 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}', @@ -133,6 +138,11 @@ class NotifyDiscord(NotifyBase): 'type': 'bool', 'default': True, }, + 'fields': { + 'name': _('Use Fields'), + 'type': 'bool', + 'default': True, + }, 'image': { 'name': _('Include Image'), 'type': 'bool', @@ -143,7 +153,7 @@ class NotifyDiscord(NotifyBase): def __init__(self, webhook_id, webhook_token, tts=False, avatar=True, footer=False, footer_logo=True, include_image=False, - avatar_url=None, **kwargs): + fields=True, avatar_url=None, **kwargs): """ Initialize Discord Object @@ -181,6 +191,9 @@ class NotifyDiscord(NotifyBase): # Place a thumbnail image inline with the message body self.include_image = include_image + # Use Fields + self.fields = fields + # Avatar URL # This allows a user to provide an over-ride to the otherwise # dynamically generated avatar url images @@ -206,32 +219,23 @@ class NotifyDiscord(NotifyBase): # Acquire image_url image_url = self.image_url(notify_type) + # our fields variable + fields = [] + if self.notify_format == NotifyFormat.MARKDOWN: # Use embeds for payload payload['embeds'] = [{ - 'provider': { + 'author': { 'name': self.app_id, 'url': self.app_url, }, 'title': title, - 'type': 'rich', 'description': body, # Our color associated with our notification 'color': self.color(notify_type, int), }] - # Break titles out so that we can sort them in embeds - fields = self.extract_markdown_sections(body) - - if len(fields) > 0: - # Apply our additional parsing for a better presentation - - # Swap first entry for description - payload['embeds'][0]['description'] = \ - fields[0].get('name') + fields[0].get('value') - payload['embeds'][0]['fields'] = fields[1:] - if self.footer: # Acquire logo URL logo_url = self.image_url(notify_type, logo=True) @@ -251,6 +255,20 @@ class NotifyDiscord(NotifyBase): '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'] = \ @@ -268,6 +286,16 @@ class NotifyDiscord(NotifyBase): # 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: # Update our payload; the idea is to preserve it's other detected # and assigned values for re-use here too @@ -413,8 +441,12 @@ class NotifyDiscord(NotifyBase): '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 + # Extend our parameters params.update(self.url_parameters(privacy=privacy, *args, **kwargs)) @@ -459,6 +491,11 @@ class NotifyDiscord(NotifyBase): # 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)) @@ -513,6 +550,18 @@ class NotifyDiscord(NotifyBase): 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<desc>[^\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(description, '', markdown, count=1) + regex = re.compile( r'\s*#[# \t\v]*(?P<name>[^\n]+)(\n|\s*$)' r'\s*((?P<value>[^#].+?)(?=\s*$|[\r\n]+\s*#))?', flags=re.S) @@ -523,9 +572,11 @@ class NotifyDiscord(NotifyBase): d = el.groupdict() fields.append({ - 'name': d.get('name', '').strip('# \r\n\t\v'), - 'value': '```md\n' + - (d.get('value').strip() if d.get('value') else '') + '\n```' + '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 fields + return description, fields diff --git a/libs/apprise/plugins/NotifyEmail.py b/libs/apprise/plugins/NotifyEmail.py index 604fc5b5c..7bd894387 100644 --- a/libs/apprise/plugins/NotifyEmail.py +++ b/libs/apprise/plugins/NotifyEmail.py @@ -106,6 +106,21 @@ EMAIL_TEMPLATES = ( }, ), + # Yandex + ( + 'Yandex', + re.compile( + r'^((?P<label>[^+]+)\+)?(?P<id>[^@]+)@' + r'(?P<domain>yandex\.(com|ru|ua|by|kz|uz|tr|fr))$', re.I), + { + 'port': 465, + 'smtp_host': 'smtp.yandex.ru', + 'secure': True, + 'secure_mode': SecureMailMode.SSL, + 'login_type': (WebBaseLogin.USERID, ) + }, + ), + # Microsoft Hotmail ( 'Microsoft Hotmail', @@ -205,21 +220,22 @@ EMAIL_TEMPLATES = ( }, ), - # Zoho Mail + # Zoho Mail (Free) ( 'Zoho Mail', re.compile( r'^((?P<label>[^+]+)\+)?(?P<id>[^@]+)@' - r'(?P<domain>zoho\.com)$', re.I), + r'(?P<domain>zoho(mail)?\.com)$', re.I), { - 'port': 465, + 'port': 587, 'smtp_host': 'smtp.zoho.com', 'secure': True, - 'secure_mode': SecureMailMode.SSL, + 'secure_mode': SecureMailMode.STARTTLS, 'login_type': (WebBaseLogin.EMAIL, ) }, ), + # SendGrid (Email Server) # You must specify an authenticated sender address in the from= settings # and a valid email in the to= to deliver your emails to @@ -285,7 +301,7 @@ class NotifyEmail(NotifyBase): default_secure_mode = SecureMailMode.STARTTLS # Default SMTP Timeout (in seconds) - connect_timeout = 15 + socket_connect_timeout = 15 # Define object templates templates = ( @@ -347,10 +363,6 @@ class NotifyEmail(NotifyBase): 'type': 'string', 'map_to': 'from_name', }, - 'smtp_host': { - 'name': _('SMTP Server'), - 'type': 'string', - }, 'cc': { 'name': _('Carbon Copy'), 'type': 'list:string', @@ -359,6 +371,11 @@ class NotifyEmail(NotifyBase): 'name': _('Blind Carbon Copy'), 'type': 'list:string', }, + 'smtp': { + 'name': _('SMTP Server'), + 'type': 'string', + 'map_to': 'smtp_host', + }, 'mode': { 'name': _('Secure Mode'), 'type': 'choice:string', @@ -366,17 +383,19 @@ class NotifyEmail(NotifyBase): 'default': SecureMailMode.STARTTLS, 'map_to': 'secure_mode', }, - 'timeout': { - 'name': _('Server Timeout'), - 'type': 'int', - 'default': 15, - 'min': 5, - }, }) - def __init__(self, timeout=15, smtp_host=None, from_name=None, + # Define any kwargs we're using + template_kwargs = { + 'headers': { + 'name': _('Email Header'), + 'prefix': '+', + }, + } + + def __init__(self, smtp_host=None, from_name=None, from_addr=None, secure_mode=None, targets=None, cc=None, - bcc=None, **kwargs): + bcc=None, headers=None, **kwargs): """ Initialize Email Object @@ -393,13 +412,6 @@ class NotifyEmail(NotifyBase): else: self.port = self.default_port - # Email SMTP Server Timeout - try: - self.timeout = int(timeout) - - except (ValueError, TypeError): - self.timeout = self.connect_timeout - # Acquire Email 'To' self.targets = list() @@ -412,6 +424,11 @@ class NotifyEmail(NotifyBase): # For tracking our email -> name lookups self.names = {} + self.headers = {} + if headers: + # Store our extra headers + self.headers.update(headers) + # Now we want to construct the To and From email # addresses from the URL provided self.from_addr = from_addr @@ -620,11 +637,11 @@ class NotifyEmail(NotifyBase): except TypeError: # Python v2.x Support (no charset keyword) # Format our cc addresses to support the Name field - cc = [formataddr( + cc = [formataddr( # pragma: no branch (self.names.get(addr, False), addr)) for addr in cc] # Format our bcc addresses to support the Name field - bcc = [formataddr( + bcc = [formataddr( # pragma: no branch (self.names.get(addr, False), addr)) for addr in bcc] self.logger.debug( @@ -646,6 +663,11 @@ class NotifyEmail(NotifyBase): content = MIMEText(body, 'plain', 'utf-8') base = MIMEMultipart() if attach else content + + # Apply any provided custom headers + for k, v in self.headers.items(): + base[k] = Header(v, 'utf-8') + base['Subject'] = Header(title, 'utf-8') try: base['From'] = formataddr( @@ -714,7 +736,7 @@ class NotifyEmail(NotifyBase): self.smtp_host, self.port, None, - timeout=self.timeout, + timeout=self.socket_connect_timeout, ) if self.secure and self.secure_mode == SecureMailMode.STARTTLS: @@ -762,10 +784,12 @@ class NotifyEmail(NotifyBase): 'from': self.from_addr, 'mode': self.secure_mode, 'smtp': self.smtp_host, - 'timeout': self.timeout, 'user': self.user, } + # Append our headers into our parameters + params.update({'+{}'.format(k): v for k, v in self.headers.items()}) + # Extend our parameters params.update(self.url_parameters(privacy=privacy, *args, **kwargs)) @@ -864,8 +888,11 @@ class NotifyEmail(NotifyBase): results['from_name'] = NotifyEmail.unquote(results['qsd']['name']) if 'timeout' in results['qsd'] and len(results['qsd']['timeout']): - # Extract the timeout to associate with smtp server - results['timeout'] = results['qsd']['timeout'] + # Deprecated in favor of cto= flag + NotifyBase.logger.deprecate( + "timeout= argument is deprecated; use cto= instead.") + results['qsd']['cto'] = results['qsd']['timeout'] + del results['qsd']['timeout'] # Store SMTP Host if specified if 'smtp' in results['qsd'] and len(results['qsd']['smtp']): @@ -887,4 +914,9 @@ class NotifyEmail(NotifyBase): results['from_addr'] = from_addr results['smtp_host'] = smtp_host + # Add our Meta Headers that the user can provide with their outbound + # emails + results['headers'] = {NotifyBase.unquote(x): NotifyBase.unquote(y) + for x, y in results['qsd+'].items()} + return results diff --git a/libs/apprise/plugins/NotifyEmby.py b/libs/apprise/plugins/NotifyEmby.py index bf9066cc0..45b5bcb3b 100644 --- a/libs/apprise/plugins/NotifyEmby.py +++ b/libs/apprise/plugins/NotifyEmby.py @@ -697,3 +697,28 @@ class NotifyEmby(NotifyBase): # ticket system as unresolved and has provided work-arounds # - https://github.com/kennethreitz/requests/issues/3578 pass + + except ImportError: # pragma: no cover + # The actual exception is `ModuleNotFoundError` however ImportError + # grants us backwards compatiblity with versions of Python older + # than v3.6 + + # Python code that makes early calls to sys.exit() can cause + # the __del__() code to run. However in some newer versions of + # Python, this causes the `sys` library to no longer be + # available. The stack overflow also goes on to suggest that + # it's not wise to use the __del__() as a deconstructor + # which is the case here. + + # https://stackoverflow.com/questions/67218341/\ + # modulenotfounderror-import-of-time-halted-none-in-sys-\ + # modules-occured-when-obj?noredirect=1&lq=1 + # + # + # Also see: https://stackoverflow.com/questions\ + # /1481488/what-is-the-del-method-and-how-do-i-call-it + + # At this time it seems clean to try to log out (if we can) + # but not throw any unessisary exceptions (like this one) to + # the end user if we don't have to. + pass diff --git a/libs/apprise/plugins/NotifyEnigma2.py b/libs/apprise/plugins/NotifyEnigma2.py index 1a8e97fcd..7161fff26 100644 --- a/libs/apprise/plugins/NotifyEnigma2.py +++ b/libs/apprise/plugins/NotifyEnigma2.py @@ -338,8 +338,12 @@ class NotifyEnigma2(NotifyBase): # 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+']) + results['headers'] = results['qsd+'] + if results['qsd-']: + results['headers'].update(results['qsd-']) + NotifyBase.logger.deprecate( + "minus (-) based Enigma header tokens are being " + " removed; use the plus (+) symbol instead.") # Tidy our header entries by unquoting them results['headers'] = { diff --git a/libs/apprise/plugins/NotifyFCM/__init__.py b/libs/apprise/plugins/NotifyFCM/__init__.py new file mode 100644 index 000000000..9269ea3b0 --- /dev/null +++ b/libs/apprise/plugins/NotifyFCM/__init__.py @@ -0,0 +1,510 @@ +# -*- coding: utf-8 -*- +# +# Copyright (C) 2021 Chris Caron <[email protected]> +# All rights reserved. +# +# This code is licensed under the MIT License. +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files(the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and / or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions : +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. + +# For this plugin to work correct, the FCM server must be set up to allow +# for remote connections. + +# Firebase Cloud Messaging +# Visit your console page: https://console.firebase.google.com +# 1. Create a project if you haven't already. If you did the +# {project} ID will be listed as name-XXXXX. +# 2. Click on your project from here to open it up. +# 3. Access your Web API Key by clicking on: +# - The (gear-next-to-project-name) > Project Settings > Cloud Messaging + +# Visit the following site to get you're Project information: +# - https://console.cloud.google.com/project/_/settings/general/ +# +# Docs: https://firebase.google.com/docs/cloud-messaging/send-message + +# Legacy Docs: +# https://firebase.google.com/docs/cloud-messaging/http-server-ref\ +# #send-downstream +# +# If you Generate a new private key, it will provide a .json file +# You will need this in order to send an apprise messag +import six +import requests +from json import dumps +from ..NotifyBase import NotifyBase +from ...common import NotifyType +from ...utils import validate_regex +from ...utils import parse_list +from ...AppriseAttachment import AppriseAttachment +from ...AppriseLocale import gettext_lazy as _ + +# Default our global support flag +NOTIFY_FCM_SUPPORT_ENABLED = False + +try: + from .oauth import GoogleOAuth + + # We're good to go + NOTIFY_FCM_SUPPORT_ENABLED = True + +except ImportError: + # cryptography is the dependency of the .oauth library + + # Create a dummy object for init() call to work + class GoogleOAuth(object): + pass + + +# Our lookup map +FCM_HTTP_ERROR_MAP = { + 400: 'A bad request was made to the server.', + 401: 'The provided API Key was not valid.', + 404: 'The token could not be registered.', +} + + +class FCMMode(object): + """ + Define the Firebase Cloud Messaging Modes + """ + # The legacy way of sending a message + Legacy = "legacy" + + # The new API + OAuth2 = "oauth2" + + +# FCM Modes +FCM_MODES = ( + # Legacy API + FCMMode.Legacy, + # HTTP v1 URL + FCMMode.OAuth2, +) + + +class NotifyFCM(NotifyBase): + """ + A wrapper for Google's Firebase Cloud Messaging Notifications + """ + + # Set our global enabled flag + enabled = NOTIFY_FCM_SUPPORT_ENABLED + + requirements = { + # Define our required packaging in order to work + 'packages_required': 'cryptography' + } + + # The default descriptive name associated with the Notification + service_name = 'Firebase Cloud Messaging' + + # The services URL + service_url = 'https://firebase.google.com' + + # The default protocol + secure_protocol = 'fcm' + + # A URL that takes you to the setup/help of the specific protocol + setup_url = 'https://github.com/caronc/apprise/wiki/Notify_fcm' + + # Project Notification + # https://firebase.google.com/docs/cloud-messaging/send-message + notify_oauth2_url = \ + "https://fcm.googleapis.com/v1/projects/{project}/messages:send" + + notify_legacy_url = "https://fcm.googleapis.com/fcm/send" + + # There is no reason we should exceed 5KB when reading in a JSON file. + # If it is more than this, then it is not accepted. + max_fcm_keyfile_size = 5000 + + # The maximum length of the body + body_maxlen = 1024 + + # A title can not be used for SMS Messages. Setting this to zero will + # cause any title (if defined) to get placed into the message body. + title_maxlen = 0 + + # Define object templates + templates = ( + # OAuth2 + '{schema}://{project}/{targets}?keyfile={keyfile}', + # Legacy Mode + '{schema}://{apikey}/{targets}', + ) + + # Define our template + template_tokens = dict(NotifyBase.template_tokens, **{ + 'apikey': { + 'name': _('API Key'), + 'type': 'string', + 'private': True, + }, + 'keyfile': { + 'name': _('OAuth2 KeyFile'), + 'type': 'string', + 'private': True, + }, + 'mode': { + 'name': _('Mode'), + 'type': 'choice:string', + 'values': FCM_MODES, + 'default': FCMMode.Legacy, + }, + 'project': { + 'name': _('Project ID'), + 'type': 'string', + 'required': True, + }, + 'target_device': { + 'name': _('Target Device'), + 'type': 'string', + 'map_to': 'targets', + }, + 'target_topic': { + 'name': _('Target Topic'), + 'type': 'string', + 'prefix': '#', + 'map_to': 'targets', + }, + 'targets': { + 'name': _('Targets'), + 'type': 'list:string', + }, + }) + + template_args = dict(NotifyBase.template_args, **{ + 'to': { + 'alias_of': 'targets', + }, + }) + + def __init__(self, project, apikey, targets=None, mode=None, keyfile=None, + **kwargs): + """ + Initialize Firebase Cloud Messaging + + """ + super(NotifyFCM, self).__init__(**kwargs) + + if mode is None: + # Detect our mode + self.mode = FCMMode.OAuth2 if keyfile else FCMMode.Legacy + + else: + # Setup our mode + self.mode = NotifyFCM.template_tokens['mode']['default'] \ + if not isinstance(mode, six.string_types) else mode.lower() + if self.mode and self.mode not in FCM_MODES: + msg = 'The mode specified ({}) is invalid.'.format(mode) + self.logger.warning(msg) + raise TypeError(msg) + + # Used for Legacy Mode; this is the Web API Key retrieved from the + # User Panel + self.apikey = None + + # Path to our Keyfile + self.keyfile = None + + # Our Project ID is required to verify against the keyfile + # specified + self.project = None + + # Initialize our Google OAuth module we can work with + self.oauth = GoogleOAuth( + user_agent=self.app_id, timeout=self.request_timeout, + verify_certificate=self.verify_certificate) + + if self.mode == FCMMode.OAuth2: + # The project ID associated with the account + self.project = validate_regex(project) + if not self.project: + msg = 'An invalid FCM Project ID ' \ + '({}) was specified.'.format(project) + self.logger.warning(msg) + raise TypeError(msg) + + if not keyfile: + msg = 'No FCM JSON KeyFile was specified.' + self.logger.warning(msg) + raise TypeError(msg) + + # Our keyfile object is just an AppriseAttachment object + self.keyfile = AppriseAttachment(asset=self.asset) + # Add our definition to our template + self.keyfile.add(keyfile) + # Enforce maximum file size + self.keyfile[0].max_file_size = self.max_fcm_keyfile_size + + else: # Legacy Mode + + # The apikey associated with the account + self.apikey = validate_regex(apikey) + if not self.apikey: + msg = 'An invalid FCM API key ' \ + '({}) was specified.'.format(apikey) + self.logger.warning(msg) + raise TypeError(msg) + + # Acquire Device IDs to notify + self.targets = parse_list(targets) + return + + @property + def access_token(self): + """ + Generates a access_token based on the keyfile provided + """ + keyfile = self.keyfile[0] + if not keyfile: + # We could not access the keyfile + self.logger.error( + 'Could not access FCM keyfile {}.'.format( + keyfile.url(privacy=True))) + return None + + if not self.oauth.load(keyfile.path): + self.logger.error( + 'FCM keyfile {} could not be loaded.'.format( + keyfile.url(privacy=True))) + return None + + # Verify our project id against the one provided in our keyfile + if self.project != self.oauth.project_id: + self.logger.error( + 'FCM keyfile {} identifies itself for a different project' + .format(keyfile.url(privacy=True))) + return None + + # Return our generated key; the below returns None if a token could + # not be acquired + return self.oauth.access_token + + def send(self, body, title='', notify_type=NotifyType.INFO, **kwargs): + """ + Perform FCM Notification + """ + + if not self.targets: + # There is no one to email; we're done + self.logger.warning('There are no FCM devices or topics to notify') + return False + + if self.mode == FCMMode.OAuth2: + access_token = self.access_token + if not access_token: + # Error message is generated in access_tokengen() so no reason + # to additionally write anything here + return False + + headers = { + 'User-Agent': self.app_id, + 'Content-Type': 'application/json', + "Authorization": "Bearer {}".format(access_token), + } + + # Prepare our notify URL + notify_url = self.notify_oauth2_url + + else: # FCMMode.Legacy + headers = { + 'User-Agent': self.app_id, + 'Content-Type': 'application/json', + "Authorization": "key={}".format(self.apikey), + } + + # Prepare our notify URL + notify_url = self.notify_legacy_url + + has_error = False + # Create a copy of the targets list + targets = list(self.targets) + while len(targets): + recipient = targets.pop(0) + + if self.mode == FCMMode.OAuth2: + payload = { + 'message': { + 'token': None, + 'notification': { + 'title': title, + 'body': body, + } + } + } + + if recipient[0] == '#': + payload['message']['topic'] = recipient[1:] + self.logger.debug( + "FCM recipient %s parsed as a topic", + recipient[1:]) + + else: + payload['message']['token'] = recipient + self.logger.debug( + "FCM recipient %s parsed as a device token", + recipient) + + else: # FCMMode.Legacy + payload = { + 'notification': { + 'notification': { + 'title': title, + 'body': body, + } + } + } + if recipient[0] == '#': + payload['to'] = '/topics/{}'.format(recipient) + self.logger.debug( + "FCM recipient %s parsed as a topic", + recipient[1:]) + + else: + payload['to'] = recipient + self.logger.debug( + "FCM recipient %s parsed as a device token", + recipient) + + self.logger.debug( + 'FCM %s POST URL: %s (cert_verify=%r)', + self.mode, notify_url, self.verify_certificate, + ) + self.logger.debug('FCM %s Payload: %s', self.mode, str(payload)) + + # Always call throttle before any remote server i/o is made + self.throttle() + try: + r = requests.post( + notify_url.format(project=self.project), + data=dumps(payload), + headers=headers, + verify=self.verify_certificate, + timeout=self.request_timeout, + ) + 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, FCM_HTTP_ERROR_MAP) + + self.logger.warning( + 'Failed to send {} FCM notification: ' + '{}{}error={}.'.format( + self.mode, + status_str, + ', ' if status_str else '', + r.status_code)) + + self.logger.debug( + 'Response Details:\r\n%s', r.content) + + has_error = True + + else: + self.logger.info('Sent %s FCM notification.', self.mode) + + except requests.RequestException as e: + self.logger.warning( + 'A Connection error occurred sending FCM ' + 'notification.' + ) + self.logger.debug('Socket Exception: %s', str(e)) + + 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 = { + 'mode': self.mode, + } + + if self.keyfile: + # Include our keyfile if specified + params['keyfile'] = NotifyFCM.quote( + self.keyfile[0].url(privacy=privacy), safe='') + + # Extend our parameters + params.update(self.url_parameters(privacy=privacy, *args, **kwargs)) + + reference = NotifyFCM.quote(self.project) \ + if self.mode == FCMMode.OAuth2 \ + else self.pprint(self.apikey, privacy, safe='') + + return '{schema}://{reference}/{targets}?{params}'.format( + schema=self.secure_protocol, + reference=reference, + targets='/'.join( + [NotifyFCM.quote(x) for x in self.targets]), + params=NotifyFCM.urlencode(params), + ) + + @staticmethod + def parse_url(url): + """ + Parses the URL and returns enough arguments that can allow + us to re-instantiate this object. + + """ + results = NotifyBase.parse_url(url, verify_host=False) + if not results: + # We're done early as we couldn't load the results + return results + + # The apikey/project is stored in the hostname + results['apikey'] = NotifyFCM.unquote(results['host']) + results['project'] = results['apikey'] + + # Get our Device IDs + results['targets'] = NotifyFCM.split_path(results['fullpath']) + + # Get our mode + results['mode'] = results['qsd'].get('mode') + + # The 'to' makes it easier to use yaml configuration + if 'to' in results['qsd'] and len(results['qsd']['to']): + results['targets'] += \ + NotifyFCM.parse_list(results['qsd']['to']) + + # Our Project ID + if 'project' in results['qsd'] and results['qsd']['project']: + results['project'] = \ + NotifyFCM.unquote(results['qsd']['project']) + + # Our Web API Key + if 'apikey' in results['qsd'] and results['qsd']['apikey']: + results['apikey'] = \ + NotifyFCM.unquote(results['qsd']['apikey']) + + # Our Keyfile (JSON) + if 'keyfile' in results['qsd'] and results['qsd']['keyfile']: + results['keyfile'] = \ + NotifyFCM.unquote(results['qsd']['keyfile']) + + return results diff --git a/libs/apprise/plugins/NotifyFCM/oauth.py b/libs/apprise/plugins/NotifyFCM/oauth.py new file mode 100644 index 000000000..95dcc3c2d --- /dev/null +++ b/libs/apprise/plugins/NotifyFCM/oauth.py @@ -0,0 +1,329 @@ +# -*- coding: utf-8 -*- +# +# Copyright (C) 2021 Chris Caron <[email protected]> +# All rights reserved. +# +# This code is licensed under the MIT License. +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files(the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and / or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions : +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. +# +# To generate a private key file for your service account: +# +# 1. In the Firebase console, open Settings > Service Accounts. +# 2. Click Generate New Private Key, then confirm by clicking Generate Key. +# 3. Securely store the JSON file containing the key. + +import io +import requests +import base64 +import json +import calendar +from cryptography.hazmat import backends +from cryptography.hazmat.primitives import hashes +from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.primitives import asymmetric +from cryptography.exceptions import UnsupportedAlgorithm +from datetime import datetime +from datetime import timedelta +from ...logger import logger + +try: + # Python 2.7 + from urllib import urlencode as _urlencode + +except ImportError: + # Python 3.x + from urllib.parse import urlencode as _urlencode + +try: + # Python 3.x + from json.decoder import JSONDecodeError + +except ImportError: + # Python v2.7 Backwards Compatibility support + JSONDecodeError = ValueError + + +class GoogleOAuth(object): + """ + A OAuth simplified implimentation to Google's Firebase Cloud Messaging + + """ + scopes = [ + 'https://www.googleapis.com/auth/firebase.messaging', + ] + + # 1 hour in seconds (the lifetime of our token) + access_token_lifetime_sec = timedelta(seconds=3600) + + # The default URI to use if one is not found + default_token_uri = 'https://oauth2.googleapis.com/token' + + # Taken right from google.auth.helpers: + clock_skew = timedelta(seconds=10) + + def __init__(self, user_agent=None, timeout=(5, 4), + verify_certificate=True): + """ + Initialize our OAuth object + """ + + # Wether or not to verify ssl + self.verify_certificate = verify_certificate + + # Our (connect, read) timeout + self.request_timeout = timeout + + # assign our user-agent if defined + self.user_agent = user_agent + + # initialize our other object variables + self.__reset() + + def __reset(self): + """ + Reset object internal variables + """ + + # Google Keyfile Encoding + self.encoding = 'utf-8' + + # Our retrieved JSON content (unmangled) + self.content = None + + # Our generated key information we cache once loaded + self.private_key = None + + # Our keys we build using the provided content + self.__refresh_token = None + self.__access_token = None + self.__access_token_expiry = datetime.utcnow() + + def load(self, path): + """ + Generate our SSL details + """ + + # Reset our objects + self.content = None + self.private_key = None + self.__access_token = None + self.__access_token_expiry = datetime.utcnow() + + try: + with io.open(path, mode="r", encoding=self.encoding) as fp: + self.content = json.loads(fp.read()) + + except (OSError, IOError): + logger.debug('FCM keyfile {} could not be accessed'.format(path)) + return False + + except JSONDecodeError as e: + logger.debug( + 'FCM keyfile {} generated a JSONDecodeError: {}'.format( + path, e)) + return False + + if not isinstance(self.content, dict): + logger.debug( + 'FCM keyfile {} is incorrectly structured'.format(path)) + self.__reset() + return False + + # Verify we've got the correct tokens in our content to work with + is_valid = next((False for k in ( + 'client_email', 'private_key_id', 'private_key', + 'type', 'project_id') if not self.content.get(k)), True) + + if not is_valid: + logger.debug( + 'FCM keyfile {} is missing required information'.format(path)) + self.__reset() + return False + + # Verify our service_account type + if self.content.get('type') != 'service_account': + logger.debug( + 'FCM keyfile {} is not of type service_account'.format(path)) + self.__reset() + return False + + # Prepare our private key which is in PKCS8 PEM format + try: + self.private_key = serialization.load_pem_private_key( + self.content.get('private_key').encode(self.encoding), + password=None, backend=backends.default_backend()) + + except (TypeError, ValueError): + # ValueError: If the PEM data could not be decrypted or if its + # structure could not be decoded successfully. + # TypeError: If a password was given and the private key was + # not encrypted. Or if the key was encrypted but + # no password was supplied. + logger.error('FCM provided private key is invalid.') + self.__reset() + return False + + except UnsupportedAlgorithm: + # If the serialized key is of a type that is not supported by + # the backend. + logger.error('FCM provided private key is not supported') + self.__reset() + return False + + # We've done enough validation to move on + return True + + @property + def access_token(self): + """ + Returns our access token (if it hasn't expired yet) + - if we do not have one we'll fetch one. + - if it expired, we'll renew it + - if a key simply can't be acquired, then we return None + """ + + if not self.private_key or not self.content: + # invalid content (or not loaded) + logger.error( + 'No FCM JSON keyfile content loaded to generate a access ' + 'token with.') + return None + + if self.__access_token_expiry > datetime.utcnow(): + # Return our no-expired key + return self.__access_token + + # If we reach here we need to prepare our payload + token_uri = self.content.get('token_uri', self.default_token_uri) + service_email = self.content.get('client_email') + key_identifier = self.content.get('private_key_id') + + # Generate our Assertion + now = datetime.utcnow() + expiry = now + self.access_token_lifetime_sec + + payload = { + # The number of seconds since the UNIX epoch. + "iat": calendar.timegm(now.utctimetuple()), + "exp": calendar.timegm(expiry.utctimetuple()), + # The issuer must be the service account email. + "iss": service_email, + # The audience must be the auth token endpoint's URI + "aud": token_uri, + # Our token scopes + "scope": " ".join(self.scopes), + } + + # JWT Details + header = { + 'typ': 'JWT', + 'alg': 'RS256' if isinstance( + self.private_key, asymmetric.rsa.RSAPrivateKey) else 'ES256', + + # Key Identifier + 'kid': key_identifier, + } + + # Encodes base64 strings removing any padding characters. + segments = [ + base64.urlsafe_b64encode( + json.dumps(header).encode(self.encoding)).rstrip(b"="), + base64.urlsafe_b64encode( + json.dumps(payload).encode(self.encoding)).rstrip(b"="), + ] + + signing_input = b".".join(segments) + signature = self.private_key.sign( + signing_input, + asymmetric.padding.PKCS1v15(), + hashes.SHA256(), + ) + + # Finally append our segment + segments.append(base64.urlsafe_b64encode(signature).rstrip(b"=")) + assertion = b".".join(segments) + + http_payload = _urlencode({ + 'assertion': assertion, + 'grant_type': 'urn:ietf:params:oauth:grant-type:jwt-bearer', + }) + + http_headers = { + 'Content-Type': 'application/x-www-form-urlencoded', + } + if self.user_agent: + http_headers['User-Agent'] = self.user_agent + + logger.info('Refreshing FCM Access Token') + try: + r = requests.post( + token_uri, + data=http_payload, + headers=http_headers, + verify=self.verify_certificate, + timeout=self.request_timeout, + ) + if r.status_code != requests.codes.ok: + # We had a problem + logger.warning( + 'Failed to update FCM Access Token error={}.' + .format(r.status_code)) + + logger.debug( + 'Response Details:\r\n%s', r.content) + return None + + except requests.RequestException as e: + logger.warning( + 'A Connection error occurred refreshing FCM ' + 'Access Token.' + ) + logger.debug('Socket Exception: %s', str(e)) + return None + + # If we get here, we made our request successfully, now we need + # to parse out the data + response = json.loads(r.content) + self.__access_token = response['access_token'] + self.__refresh_token = response.get( + 'refresh_token', self.__refresh_token) + + if 'expires_in' in response: + delta = timedelta(seconds=int(response['expires_in'])) + self.__access_token_expiry = \ + delta + datetime.utcnow() - self.clock_skew + + else: + # Allow some grace before we expire + self.__access_token_expiry = expiry - self.clock_skew + + logger.debug( + 'Access Token successfully acquired: %s', self.__access_token) + + # Return our token + return self.__access_token + + @property + def project_id(self): + """ + Returns the project id found in the file + """ + return None if not self.content \ + else self.content.get('project_id') diff --git a/libs/apprise/plugins/NotifyGitter.py b/libs/apprise/plugins/NotifyGitter.py index d94d41469..577959836 100644 --- a/libs/apprise/plugins/NotifyGitter.py +++ b/libs/apprise/plugins/NotifyGitter.py @@ -284,7 +284,7 @@ class NotifyGitter(NotifyBase): # By default set wait to None wait = None - if self.ratelimit_remaining == 0: + if self.ratelimit_remaining <= 0: # Determine how long we should wait for or if we should wait at # all. This isn't fool-proof because we can't be sure the client # time (calling this script) is completely synced up with the diff --git a/libs/apprise/plugins/NotifyGnome.py b/libs/apprise/plugins/NotifyGnome.py index 4f5e58606..6317c0d54 100644 --- a/libs/apprise/plugins/NotifyGnome.py +++ b/libs/apprise/plugins/NotifyGnome.py @@ -78,8 +78,19 @@ class NotifyGnome(NotifyBase): A wrapper for local Gnome Notifications """ + # Set our global enabled flag + enabled = NOTIFY_GNOME_SUPPORT_ENABLED + + requirements = { + # Define our required packaging in order to work + 'details': _('A local Gnome environment is required.') + } + # The default descriptive name associated with the Notification - service_name = 'Gnome Notification' + service_name = _('Gnome Notification') + + # The service URL + service_url = 'https://www.gnome.org/' # The default protocol protocol = 'gnome' @@ -102,15 +113,6 @@ class NotifyGnome(NotifyBase): # cause any title (if defined) to get placed into the message body. title_maxlen = 0 - # This entry is a bit hacky, but it allows us to unit-test this library - # in an environment that simply doesn't have the gnome packages - # available to us. It also allows us to handle situations where the - # packages actually are present but we need to test that they aren't. - # If anyone is seeing this had knows a better way of testing this - # outside of what is defined in test/test_gnome_plugin.py, please - # let me know! :) - _enabled = NOTIFY_GNOME_SUPPORT_ENABLED - # Define object templates templates = ( '{schema}://', @@ -157,11 +159,6 @@ class NotifyGnome(NotifyBase): Perform Gnome Notification """ - if not self._enabled: - self.logger.warning( - "Gnome Notifications are not supported by this system.") - return False - try: # App initialization Notify.init(self.app_id) diff --git a/libs/apprise/plugins/NotifyGoogleChat.py b/libs/apprise/plugins/NotifyGoogleChat.py new file mode 100644 index 000000000..2cba98405 --- /dev/null +++ b/libs/apprise/plugins/NotifyGoogleChat.py @@ -0,0 +1,315 @@ +# -*- coding: utf-8 -*- +# +# Copyright (C) 2021 Chris Caron <[email protected]> +# All rights reserved. +# +# This code is licensed under the MIT License. +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files(the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and / or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions : +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. + +# For this to work correctly you need to create a webhook. You'll also +# need a GSuite account (there are free trials if you don't have one) +# +# - Open Google Chat in your browser: +# Link: https://chat.google.com/ +# - Go to the room to which you want to add a bot. +# - From the room menu at the top of the page, select Manage webhooks. +# - Provide it a name and optional avatar and click SAVE +# - Copy the URL listed next to your new webhook in the Webhook URL column. +# - Click outside the dialog box to close. +# +# When you've completed, you'll get a URL that looks a little like this: +# https://chat.googleapis.com/v1/spaces/AAAAk6lGXyM/\ +# messages?key=AIzaSyDdI0hCZtE6vySjMm-WEfRq3CPzqKqqsHI&\ +# token=O7b1nyri_waOpLMSzbFILAGRzgtQofPW71fEEXKcyFk%3D +# +# Simplified, it looks like this: +# https://chat.googleapis.com/v1/spaces/WORKSPACE/messages?\ +# key=WEBHOOK_KEY&token=WEBHOOK_TOKEN +# +# This plugin will simply work using the url of: +# gchat://WORKSPACE/WEBHOOK_KEY/WEBHOOK_TOKEN +# +# API Documentation on Webhooks: +# - https://developers.google.com/hangouts/chat/quickstart/\ +# incoming-bot-python +# - https://developers.google.com/hangouts/chat/reference/rest +# +import re +import requests +from json import dumps + +from .NotifyBase import NotifyBase +from ..common import NotifyFormat +from ..common import NotifyType +from ..utils import validate_regex +from ..AppriseLocale import gettext_lazy as _ + + +class NotifyGoogleChat(NotifyBase): + """ + A wrapper to Google Chat Notifications + + """ + # The default descriptive name associated with the Notification + service_name = 'Google Chat' + + # The services URL + service_url = 'https://chat.google.com/' + + # The default secure protocol + secure_protocol = 'gchat' + + # A URL that takes you to the setup/help of the specific protocol + setup_url = 'https://github.com/caronc/apprise/wiki/Notify_googlechat' + + # Google Chat Webhook + notify_url = 'https://chat.googleapis.com/v1/spaces/{workspace}/messages' \ + '?key={key}&token={token}' + + # Default Notify Format + notify_format = NotifyFormat.MARKDOWN + + # A title can not be used for Google Chat Messages. Setting this to zero + # will cause any title (if defined) to get placed into the message body. + title_maxlen = 0 + + # The maximum allowable characters allowed in the body per message + body_maxlen = 4000 + + # Define object templates + templates = ( + '{schema}://{workspace}/{webhook_key}/{webhook_token}', + ) + + # Define our template tokens + template_tokens = dict(NotifyBase.template_tokens, **{ + 'workspace': { + 'name': _('Workspace'), + 'type': 'string', + 'private': True, + 'required': True, + }, + 'webhook_key': { + 'name': _('Webhook Key'), + '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, **{ + 'workspace': { + 'alias_of': 'workspace', + }, + 'key': { + 'alias_of': 'webhook_key', + }, + 'token': { + 'alias_of': 'webhook_token', + }, + }) + + def __init__(self, workspace, webhook_key, webhook_token, **kwargs): + """ + Initialize Google Chat Object + + """ + super(NotifyGoogleChat, self).__init__(**kwargs) + + # Workspace (associated with project) + self.workspace = validate_regex(workspace) + if not self.workspace: + msg = 'An invalid Google Chat Workspace ' \ + '({}) was specified.'.format(workspace) + self.logger.warning(msg) + raise TypeError(msg) + + # Webhook Key (associated with project) + self.webhook_key = validate_regex(webhook_key) + if not self.webhook_key: + msg = 'An invalid Google Chat Webhook Key ' \ + '({}) was specified.'.format(webhook_key) + 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 Google Chat Webhook Token ' \ + '({}) was specified.'.format(webhook_token) + self.logger.warning(msg) + raise TypeError(msg) + + return + + def send(self, body, title='', notify_type=NotifyType.INFO, **kwargs): + """ + Perform Google Chat Notification + """ + + # Our headers + headers = { + 'User-Agent': self.app_id, + 'Content-Type': 'application/json; charset=utf-8', + } + + payload = { + # Our Message + 'text': body, + } + + # Construct Notify URL + notify_url = self.notify_url.format( + workspace=self.workspace, + key=self.webhook_key, + token=self.webhook_token, + ) + + self.logger.debug('Google Chat POST URL: %s (cert_verify=%r)' % ( + notify_url, self.verify_certificate, + )) + self.logger.debug('Google Chat 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, + ) + 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) + + self.logger.warning( + 'Failed to send Google Chat 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 Google Chat notification.') + + except requests.RequestException as e: + self.logger.warning( + 'A Connection error occurred postingto Google Chat.') + self.logger.debug('Socket Exception: %s' % str(e)) + return False + + return True + + def url(self, privacy=False, *args, **kwargs): + """ + Returns the URL built dynamically based on specified arguments. + """ + + # Set our parameters + params = self.url_parameters(privacy=privacy, *args, **kwargs) + + return '{schema}://{workspace}/{key}/{token}/?{params}'.format( + schema=self.secure_protocol, + workspace=self.pprint(self.workspace, privacy, safe=''), + key=self.pprint(self.webhook_key, privacy, safe=''), + token=self.pprint(self.webhook_token, privacy, safe=''), + params=NotifyGoogleChat.urlencode(params), + ) + + @staticmethod + def parse_url(url): + """ + Parses the URL and returns enough arguments that can allow + us to re-instantiate this object. + + Syntax: + gchat://workspace/webhook_key/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 Workspace + results['workspace'] = NotifyGoogleChat.unquote(results['host']) + + # Acquire our tokens + tokens = NotifyGoogleChat.split_path(results['fullpath']) + + # Store our Webhook Key + results['webhook_key'] = tokens.pop(0) if tokens else None + + # Store our Webhook Token + results['webhook_token'] = tokens.pop(0) if tokens else None + + # Support arguments as overrides (if specified) + if 'workspace' in results['qsd']: + results['workspace'] = \ + NotifyGoogleChat.unquote(results['qsd']['workspace']) + + if 'key' in results['qsd']: + results['webhook_key'] = \ + NotifyGoogleChat.unquote(results['qsd']['key']) + + if 'token' in results['qsd']: + results['webhook_token'] = \ + NotifyGoogleChat.unquote(results['qsd']['token']) + + return results + + @staticmethod + def parse_native_url(url): + """ + Support + https://chat.googleapis.com/v1/spaces/{workspace}/messages + '?key={key}&token={token} + """ + + result = re.match( + r'^https://chat\.googleapis\.com/v1/spaces/' + r'(?P<workspace>[A-Z0-9_-]+)/messages/*(?P<params>.+)$', + url, re.I) + + if result: + return NotifyGoogleChat.parse_url( + '{schema}://{workspace}/{params}'.format( + schema=NotifyGoogleChat.secure_protocol, + workspace=result.group('workspace'), + params=result.group('params'))) + + return None diff --git a/libs/apprise/plugins/NotifyGotify.py b/libs/apprise/plugins/NotifyGotify.py index a04a69526..3b8b17589 100644 --- a/libs/apprise/plugins/NotifyGotify.py +++ b/libs/apprise/plugins/NotifyGotify.py @@ -35,7 +35,7 @@ import requests from json import dumps from .NotifyBase import NotifyBase -from ..common import NotifyType +from ..common import NotifyType, NotifyFormat from ..utils import validate_regex from ..AppriseLocale import gettext_lazy as _ @@ -182,6 +182,13 @@ class NotifyGotify(NotifyBase): 'message': body, } + if self.notify_format == NotifyFormat.MARKDOWN: + payload["extras"] = { + "client::display": { + "contentType": "text/markdown" + } + } + # Our headers headers = { 'User-Agent': self.app_id, diff --git a/libs/apprise/plugins/NotifyGrowl.py b/libs/apprise/plugins/NotifyGrowl.py index e9df69dc5..446ad660e 100644 --- a/libs/apprise/plugins/NotifyGrowl.py +++ b/libs/apprise/plugins/NotifyGrowl.py @@ -68,6 +68,13 @@ class NotifyGrowl(NotifyBase): A wrapper to Growl Notifications """ + # Set our global enabled flag + enabled = NOTIFY_GROWL_SUPPORT_ENABLED + + requirements = { + # Define our required packaging in order to work + 'packages_required': 'gntp' + } # The default descriptive name associated with the Notification service_name = 'Growl' @@ -84,15 +91,6 @@ class NotifyGrowl(NotifyBase): # Allows the user to specify the NotifyImageSize object image_size = NotifyImageSize.XY_72 - # This entry is a bit hacky, but it allows us to unit-test this library - # in an environment that simply doesn't have the windows packages - # available to us. It also allows us to handle situations where the - # packages actually are present but we need to test that they aren't. - # If anyone is seeing this had knows a better way of testing this - # outside of what is defined in test/test_growl_plugin.py, please - # let me know! :) - _enabled = NOTIFY_GROWL_SUPPORT_ENABLED - # Disable throttle rate for Growl requests since they are normally # local anyway request_rate_per_sec = 0 @@ -251,13 +249,6 @@ class NotifyGrowl(NotifyBase): """ Perform Growl Notification """ - - if not self._enabled: - self.logger.warning( - "Growl Notifications are not supported by this system; " - "`pip install gntp`.") - return False - # Register ourselves with the server if we haven't done so already if not self.growl and not self.register(): # We failed to register @@ -395,15 +386,27 @@ class NotifyGrowl(NotifyBase): if 'priority' in results['qsd'] and len(results['qsd']['priority']): _map = { + # Letter Assignments 'l': GrowlPriority.LOW, 'm': GrowlPriority.MODERATE, 'n': GrowlPriority.NORMAL, 'h': GrowlPriority.HIGH, 'e': GrowlPriority.EMERGENCY, + 'lo': GrowlPriority.LOW, + 'me': GrowlPriority.MODERATE, + 'no': GrowlPriority.NORMAL, + 'hi': GrowlPriority.HIGH, + 'em': GrowlPriority.EMERGENCY, + # Support 3rd Party Documented Scale + '-2': GrowlPriority.LOW, + '-1': GrowlPriority.MODERATE, + '0': GrowlPriority.NORMAL, + '1': GrowlPriority.HIGH, + '2': GrowlPriority.EMERGENCY, } try: results['priority'] = \ - _map[results['qsd']['priority'][0].lower()] + _map[results['qsd']['priority'][0:2].lower()] except KeyError: # No priority was set diff --git a/libs/apprise/plugins/NotifyHomeAssistant.py b/libs/apprise/plugins/NotifyHomeAssistant.py new file mode 100644 index 000000000..59e6f06fe --- /dev/null +++ b/libs/apprise/plugins/NotifyHomeAssistant.py @@ -0,0 +1,310 @@ +# -*- coding: utf-8 -*- +# +# Copyright (C) 2021 Chris Caron <[email protected]> +# All rights reserved. +# +# This code is licensed under the MIT License. +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files(the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and / or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions : +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. + +# You must generate a "Long-Lived Access Token". This can be done from your +# Home Assistant Profile page. + +import requests +from json import dumps + +from uuid import uuid4 + +from .NotifyBase import NotifyBase +from ..URLBase import PrivacyMode +from ..common import NotifyType +from ..utils import validate_regex +from ..AppriseLocale import gettext_lazy as _ + + +class NotifyHomeAssistant(NotifyBase): + """ + A wrapper for Home Assistant Notifications + """ + + # The default descriptive name associated with the Notification + service_name = 'HomeAssistant' + + # The services URL + service_url = 'https://www.home-assistant.io/' + + # Insecure Protocol Access + protocol = 'hassio' + + # Secure Protocol + secure_protocol = 'hassios' + + # Default to Home Assistant Default Insecure port of 8123 instead of 80 + default_insecure_port = 8123 + + # A URL that takes you to the setup/help of the specific protocol + setup_url = 'https://github.com/caronc/apprise/wiki/Notify_homeassistant' + + # Define object templates + templates = ( + '{schema}://{host}/{accesstoken}', + '{schema}://{host}:{port}/{accesstoken}', + '{schema}://{user}@{host}/{accesstoken}', + '{schema}://{user}@{host}:{port}/{accesstoken}', + '{schema}://{user}:{password}@{host}/{accesstoken}', + '{schema}://{user}:{password}@{host}:{port}/{accesstoken}', + ) + + # Define our template tokens + 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, + }, + 'accesstoken': { + 'name': _('Long-Lived Access Token'), + 'type': 'string', + 'private': True, + 'required': True, + }, + }) + + # Define our template arguments + template_args = dict(NotifyBase.template_args, **{ + 'nid': { + # Optional Unique Notification ID + 'name': _('Notification ID'), + 'type': 'string', + 'regex': (r'^[a-f0-9_-]+$', 'i'), + }, + }) + + def __init__(self, accesstoken, nid=None, **kwargs): + """ + Initialize Home Assistant Object + """ + super(NotifyHomeAssistant, self).__init__(**kwargs) + + self.fullpath = kwargs.get('fullpath', '') + + if not (self.secure or self.port): + # Use default insecure port + self.port = self.default_insecure_port + + # Long-Lived Access token (generated from User Profile) + self.accesstoken = validate_regex(accesstoken) + if not self.accesstoken: + msg = 'An invalid Home Assistant Long-Lived Access Token ' \ + '({}) was specified.'.format(accesstoken) + self.logger.warning(msg) + raise TypeError(msg) + + # An Optional Notification Identifier + self.nid = None + if nid: + self.nid = validate_regex( + nid, *self.template_args['nid']['regex']) + if not self.nid: + msg = 'An invalid Home Assistant Notification Identifier ' \ + '({}) was specified.'.format(nid) + self.logger.warning(msg) + raise TypeError(msg) + + return + + def send(self, body, title='', notify_type=NotifyType.INFO, **kwargs): + """ + Sends Message + """ + + # Prepare our persistent_notification.create payload + payload = { + 'title': title, + 'message': body, + # Use a unique ID so we don't over-write the last message + # we posted. Otherwise use the notification id specified + 'notification_id': self.nid if self.nid else str(uuid4()), + } + + # Prepare our headers + headers = { + 'User-Agent': self.app_id, + 'Content-Type': 'application/json', + 'Authorization': 'Bearer {}'.format(self.accesstoken), + } + + auth = None + if self.user: + auth = (self.user, self.password) + + # Set our schema + schema = 'https' if self.secure else 'http' + + url = '{}://{}'.format(schema, self.host) + if isinstance(self.port, int): + url += ':%d' % self.port + + url += '' if not self.fullpath else '/' + self.fullpath.strip('/') + url += '/api/services/persistent_notification/create' + + self.logger.debug('Home Assistant POST URL: %s (cert_verify=%r)' % ( + url, self.verify_certificate, + )) + self.logger.debug('Home Assistant Payload: %s' % str(payload)) + + # Always call throttle before any remote server i/o is made + self.throttle() + + try: + r = requests.post( + url, + data=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 = \ + NotifyHomeAssistant.http_response_code_lookup( + r.status_code) + + self.logger.warning( + 'Failed to send Home Assistant 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 Home Assistant notification.') + + except requests.RequestException as e: + self.logger.warning( + 'A Connection error occurred sending Home Assistant ' + 'notification to %s.' % self.host) + self.logger.debug('Socket Exception: %s' % str(e)) + + # Return; we're done + return False + + return True + + def url(self, privacy=False, *args, **kwargs): + """ + Returns the URL built dynamically based on specified arguments. + """ + + # Define any URL parameters + params = {} + if self.nid: + params['nid'] = self.nid + + # 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=NotifyHomeAssistant.quote(self.user, safe=''), + password=self.pprint( + self.password, privacy, mode=PrivacyMode.Secret, safe=''), + ) + elif self.user: + auth = '{user}@'.format( + user=NotifyHomeAssistant.quote(self.user, safe=''), + ) + + default_port = 443 if self.secure else self.default_insecure_port + + url = '{schema}://{auth}{hostname}{port}{fullpath}' \ + '{accesstoken}/?{params}' + + return url.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 not self.port or self.port == default_port + else ':{}'.format(self.port), + fullpath='/' if not self.fullpath else '/{}/'.format( + NotifyHomeAssistant.quote(self.fullpath.strip('/'), safe='/')), + accesstoken=self.pprint(self.accesstoken, privacy, safe=''), + params=NotifyHomeAssistant.urlencode(params), + ) + + @staticmethod + def parse_url(url): + """ + Parses the URL and returns enough arguments that can allow + us to re-instantiate this object. + + """ + + results = NotifyBase.parse_url(url, verify_host=False) + if not results: + # We're done early as we couldn't load the results + return results + + # Get our Long-Lived Access Token + if 'accesstoken' in results['qsd'] and \ + len(results['qsd']['accesstoken']): + results['accesstoken'] = \ + NotifyHomeAssistant.unquote(results['qsd']['accesstoken']) + + else: + # Acquire our full path + fullpath = NotifyHomeAssistant.split_path(results['fullpath']) + + # Otherwise pop the last element from our path to be it + results['accesstoken'] = fullpath.pop() if fullpath else None + + # Re-assemble our full path + results['fullpath'] = '/'.join(fullpath) + + # Allow the specification of a unique notification_id so that + # it will always replace the last one sent. + if 'nid' in results['qsd'] and len(results['qsd']['nid']): + results['nid'] = \ + NotifyHomeAssistant.unquote(results['qsd']['nid']) + + return results diff --git a/libs/apprise/plugins/NotifyIFTTT.py b/libs/apprise/plugins/NotifyIFTTT.py index e6b40acd2..b735a4d07 100644 --- a/libs/apprise/plugins/NotifyIFTTT.py +++ b/libs/apprise/plugins/NotifyIFTTT.py @@ -325,6 +325,10 @@ class NotifyIFTTT(NotifyBase): # Unquote our API Key results['webhook_id'] = NotifyIFTTT.unquote(results['webhook_id']) + # Parse our add_token and del_token arguments (if specified) + results['add_token'] = results['qsd+'] + results['del_token'] = results['qsd-'] + # Our Event results['events'] = list() if results['user']: @@ -351,7 +355,7 @@ class NotifyIFTTT(NotifyBase): result = re.match( r'^https?://maker\.ifttt\.com/use/' r'(?P<webhook_id>[A-Z0-9_-]+)' - r'/?(?P<events>([A-Z0-9_-]+/?)+)?' + r'((?P<events>(/[A-Z0-9_-]+)+))?' r'/?(?P<params>\?.+)?$', url, re.I) if result: diff --git a/libs/apprise/plugins/NotifyJSON.py b/libs/apprise/plugins/NotifyJSON.py index d8a55ac82..c03670331 100644 --- a/libs/apprise/plugins/NotifyJSON.py +++ b/libs/apprise/plugins/NotifyJSON.py @@ -25,6 +25,7 @@ import six import requests +import base64 from json import dumps from .NotifyBase import NotifyBase @@ -160,11 +161,50 @@ class NotifyJSON(NotifyBase): params=NotifyJSON.urlencode(params), ) - def send(self, body, title='', notify_type=NotifyType.INFO, **kwargs): + def send(self, body, title='', notify_type=NotifyType.INFO, attach=None, + **kwargs): """ Perform JSON Notification """ + headers = { + 'User-Agent': self.app_id, + 'Content-Type': 'application/json' + } + + # Apply any/all header over-rides defined + headers.update(self.headers) + + # Track our potential attachments + attachments = [] + if attach: + for attachment in attach: + # 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: + 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, + }) + + 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 JSON Object payload = { # Version: Major.Minor, Major is only updated if the entire @@ -173,17 +213,10 @@ class NotifyJSON(NotifyBase): 'version': '1.0', 'title': title, 'message': body, + 'attachments': attachments, 'type': notify_type, } - headers = { - 'User-Agent': self.app_id, - 'Content-Type': 'application/json' - } - - # Apply any/all header over-rides defined - headers.update(self.headers) - auth = None if self.user: auth = (self.user, self.password) @@ -259,8 +292,12 @@ class NotifyJSON(NotifyBase): # 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+']) + results['headers'] = results['qsd+'] + if results['qsd-']: + results['headers'].update(results['qsd-']) + NotifyBase.logger.deprecate( + "minus (-) based JSON header tokens are being " + " removed; use the plus (+) symbol instead.") # Tidy our header entries by unquoting them results['headers'] = {NotifyJSON.unquote(x): NotifyJSON.unquote(y) diff --git a/libs/apprise/plugins/NotifyKavenegar.py b/libs/apprise/plugins/NotifyKavenegar.py index cd5726367..97c69366e 100644 --- a/libs/apprise/plugins/NotifyKavenegar.py +++ b/libs/apprise/plugins/NotifyKavenegar.py @@ -32,13 +32,13 @@ # This provider does not accept +1 (for example) as a country code. You need # to specify 001 instead. # -import re import requests from json import loads from .NotifyBase import NotifyBase from ..common import NotifyType -from ..utils import parse_list +from ..utils import is_phone_no +from ..utils import parse_phone_no from ..utils import validate_regex from ..AppriseLocale import gettext_lazy as _ @@ -68,9 +68,6 @@ KAVENEGAR_HTTP_ERROR_MAP = { 501: 'SMS can only be sent to the account holder number', } -# Some Phone Number Detection -IS_PHONE_NO = re.compile(r'^\+?(?P<phone>[0-9\s)(+-]+)\s*$') - class NotifyKavenegar(NotifyBase): """ @@ -165,53 +162,31 @@ class NotifyKavenegar(NotifyBase): self.source = None if source is not None: - result = IS_PHONE_NO.match(source) + result = is_phone_no(source) if not result: msg = 'The Kavenegar source specified ({}) is invalid.'\ .format(source) self.logger.warning(msg) raise TypeError(msg) - # 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: - msg = 'The MessageBird source # specified ({}) is invalid.'\ - .format(source) - self.logger.warning(msg) - raise TypeError(msg) - # Store our source - self.source = result + self.source = result['full'] # Parse our targets self.targets = list() - for target in parse_list(targets): + for target in parse_phone_no(targets): # Validate targets and drop bad ones: - result = IS_PHONE_NO.match(target) - if result: - # Further check our phone # for it's digit count - # if it's less than 10, then we can assume it's - # a poorly specified phone no and spit a warning - 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) + result = is_phone_no(target) + if not result: + self.logger.warning( + 'Dropped invalid phone # ' + '({}) specified.'.format(target), + ) continue - self.logger.warning( - 'Dropped invalid phone # ({}) specified.'.format(target)) - - if len(self.targets) == 0: - msg = 'There are no valid targets identified to notify.' - self.logger.warning(msg) - raise TypeError(msg) + # store valid phone number + self.targets.append(result['full']) return @@ -220,6 +195,11 @@ class NotifyKavenegar(NotifyBase): Sends SMS Message """ + if len(self.targets) == 0: + # There were no services to notify + self.logger.warning('There were no Kavenegar targets to notify.') + return False + # error tracking (used for function return) has_error = False @@ -364,7 +344,7 @@ class NotifyKavenegar(NotifyBase): # The 'to' makes it easier to use yaml configuration if 'to' in results['qsd'] and len(results['qsd']['to']): results['targets'] += \ - NotifyKavenegar.parse_list(results['qsd']['to']) + NotifyKavenegar.parse_phone_no(results['qsd']['to']) if 'from' in results['qsd'] and len(results['qsd']['from']): results['source'] = \ diff --git a/libs/apprise/plugins/NotifyLametric.py b/libs/apprise/plugins/NotifyLametric.py index a8938e651..3c616d044 100644 --- a/libs/apprise/plugins/NotifyLametric.py +++ b/libs/apprise/plugins/NotifyLametric.py @@ -27,15 +27,39 @@ # website. it can be done as follows: # Cloud Mode: -# 1. Sign Up and login to the developer webpage https://developer.lametric.com -# 2. Create a **Notification App** if you haven't already done so from: -# https://developer.lametric.com/applications/sources -# 3. Provide it an app name, a description and privacy URL (which can point to -# anywhere; I set mine to `http://localhost`). No permissions are -# required. -# 4. Access your newly created app so that you can acquire both the -# **Client ID** and the **Client Secret** here: -# https://developer.lametric.com/applications/sources +# - Sign Up and login to the developer webpage https://developer.lametric.com +# +# - Create a **Indicator App** if you haven't already done so from here: +# https://developer.lametric.com/applications/sources +# +# There is a great official tutorial on how to do this here: +# https://lametric-documentation.readthedocs.io/en/latest/\ +# guides/first-steps/first-lametric-indicator-app.html +# +# - Make sure to set the **Communication Type** to **PUSH**. +# +# - You will be able to **Publish** your app once you've finished setting it +# up. This will allow it to be accessible from the internet using the +# `cloud` mode of this Apprise Plugin. The **Publish** button shows up +# from within the settings of your Lametric App upon clicking on the +# **Draft Vx** folder (where `x` is the version - usually a 1) +# +# When you've completed, the site would have provided you a **PUSH URL** that +# looks like this: +# https://developer.lametric.com/api/v1/dev/widget/update/\ +# com.lametric.{app_id}/{app_ver} +# +# You will need to record the `{app_id}` and `{app_ver}` to use the `cloud` +# mode. +# +# The same page should also provide you with an **Access Token**. It's +# approximately 86 characters with two equal (`=`) characters at the end of it. +# This becomes your `{app_token}`. Here is an example of what one might +# look like: +# K2MxWI0NzU0ZmI2NjJlZYTgViMDgDRiN8YjlmZjRmNTc4NDVhJzk0RiNjNh0EyKWW==` +# +# The syntax for the cloud mode is: +# * `lametric://{app_token}@{app_id}/{app_ver}?mode=cloud` # Device Mode: # - Sign Up and login to the developer webpage https://developer.lametric.com @@ -44,11 +68,14 @@ # - From here you can get your your API Key for the device you plan to notify. # - Your devices IP Address can be found in LaMetric Time app at: # Settings -> Wi-Fi -> IP Address +# +# The syntax for the device mode is: +# * `lametric://{apikey}@{host}` # A great source for API examples (Device Mode): # - https://lametric-documentation.readthedocs.io/en/latest/reference-docs\ # /device-notifications.html - +# # A great source for API examples (Cloud Mode): # - https://lametric-documentation.readthedocs.io/en/latest/reference-docs\ # /lametric-cloud-reference.html @@ -56,18 +83,26 @@ # A great source for the icon reference: # - https://developer.lametric.com/icons + import re import six import requests from json import dumps from .NotifyBase import NotifyBase -from ..URLBase import PrivacyMode from ..common import NotifyType from ..utils import validate_regex from ..AppriseLocale import gettext_lazy as _ from ..utils import is_hostname from ..utils import is_ipaddr +# A URL Parser to detect App ID +LAMETRIC_APP_ID_DETECTOR_RE = re.compile( + r'(com\.lametric\.)?(?P<app_id>[0-9a-z.-]{1,64})' + r'(/(?P<app_ver>[1-9][0-9]*))?', re.I) + +# Tokens are huge +LAMETRIC_IS_APP_TOKEN = re.compile(r'^[a-z0-9]{80,}==$', re.I) + class LametricMode(object): """ @@ -295,7 +330,7 @@ class NotifyLametric(NotifyBase): # URL used for notifying Lametric App's created in the Dev Portal cloud_notify_url = 'https://developer.lametric.com/api/v1' \ - '/dev/widget/update/com.lametric.{client_id}' + '/dev/widget/update/com.lametric.{app_id}/{app_ver}' # URL used for local notifications directly to the device device_notify_url = '{schema}://{host}{port}/api/v2/device/notifications' @@ -323,8 +358,9 @@ class NotifyLametric(NotifyBase): # Define object templates templates = ( - # App Mode - '{schema}://{client_id}@{secret}', + # Cloud (App) Mode + '{schema}://{app_token}@{app_id}', + '{schema}://{app_token}@{app_id}/{app_ver}', # Device Mode '{schema}://{apikey}@{host}', @@ -334,11 +370,31 @@ class NotifyLametric(NotifyBase): # Define our template tokens template_tokens = dict(NotifyBase.template_tokens, **{ + # Used for Local Device mode 'apikey': { 'name': _('Device API Key'), 'type': 'string', 'private': True, }, + # Used for Cloud mode + 'app_id': { + 'name': _('App ID'), + 'type': 'string', + 'private': True, + }, + # Used for Cloud mode + 'app_ver': { + 'name': _('App Version'), + 'type': 'string', + 'regex': (r'^[1-9][0-9]*$', ''), + 'default': '1', + }, + # Used for Cloud mode + 'app_token': { + 'name': _('App Access Token'), + 'type': 'string', + 'regex': (r'^[A-Z0-9]{80,}==$', 'i'), + }, 'host': { 'name': _('Hostname'), 'type': 'string', @@ -355,30 +411,22 @@ class NotifyLametric(NotifyBase): 'name': _('Username'), 'type': 'string', }, - 'client_id': { - 'name': _('Client ID'), - 'type': 'string', - 'private': True, - 'regex': (r'^[a-z0-9-]+$', 'i'), - }, - 'secret': { - 'name': _('Client Secret'), - 'type': 'string', - 'private': True, - }, }) # Define our template arguments template_args = dict(NotifyBase.template_args, **{ - 'oauth_id': { - 'alias_of': 'client_id', - }, - 'oauth_secret': { - 'alias_of': 'secret', - }, 'apikey': { 'alias_of': 'apikey', }, + 'app_id': { + 'alias_of': 'app_id', + }, + 'app_ver': { + 'alias_of': 'app_ver', + }, + 'app_token': { + 'alias_of': 'app_token', + }, 'priority': { 'name': _('Priority'), 'type': 'choice:string', @@ -414,9 +462,9 @@ class NotifyLametric(NotifyBase): }, }) - def __init__(self, apikey=None, client_id=None, secret=None, priority=None, - icon=None, icon_type=None, sound=None, mode=None, - cycles=None, **kwargs): + def __init__(self, apikey=None, app_token=None, app_id=None, + app_ver=None, priority=None, icon=None, icon_type=None, + sound=None, mode=None, cycles=None, **kwargs): """ Initialize LaMetric Object """ @@ -426,41 +474,61 @@ class NotifyLametric(NotifyBase): if isinstance(mode, six.string_types) \ else self.template_args['mode']['default'] + # Default Cloud Argument + self.lametric_app_id = None + self.lametric_app_ver = None + self.lametric_app_access_token = None + + # Default Device/Cloud Argument + self.lametric_apikey = None + if self.mode not in LAMETRIC_MODES: msg = 'An invalid LaMetric Mode ({}) was specified.'.format(mode) self.logger.warning(msg) raise TypeError(msg) - # Default Cloud Arguments - self.secret = None - self.client_id = None - - # Default Device Arguments - self.apikey = None - if self.mode == LametricMode.CLOUD: - # Client ID - self.client_id = validate_regex( - client_id, *self.template_tokens['client_id']['regex']) - if not self.client_id: - msg = 'An invalid LaMetric Client OAuth2 ID ' \ - '({}) was specified.'.format(client_id) + try: + results = LAMETRIC_APP_ID_DETECTOR_RE.match(app_id) + except TypeError: + msg = 'An invalid LaMetric Application ID ' \ + '({}) was specified.'.format(app_id) self.logger.warning(msg) raise TypeError(msg) - # Client Secret - self.secret = validate_regex(secret) - if not self.secret: - msg = 'An invalid LaMetric Client OAuth2 Secret ' \ - '({}) was specified.'.format(secret) + # Detect our Access Token + self.lametric_app_access_token = validate_regex( + app_token, + *self.template_tokens['app_token']['regex']) + if not self.lametric_app_access_token: + msg = 'An invalid LaMetric Application Access Token ' \ + '({}) was specified.'.format(app_token) self.logger.warning(msg) raise TypeError(msg) - else: # LametricMode.DEVICE + # If app_ver is specified, it over-rides all + if app_ver: + self.lametric_app_ver = validate_regex( + app_ver, *self.template_tokens['app_ver']['regex']) + if not self.lametric_app_ver: + msg = 'An invalid LaMetric Application Version ' \ + '({}) was specified.'.format(app_ver) + self.logger.warning(msg) + raise TypeError(msg) - # API Key - self.apikey = validate_regex(apikey) - if not self.apikey: + else: + # If app_ver wasn't specified, we parse it from the + # Application ID + self.lametric_app_ver = results.group('app_ver') \ + if results.group('app_ver') else \ + self.template_tokens['app_ver']['default'] + + # Store our Application ID + self.lametric_app_id = results.group('app_id') + + if self.mode == LametricMode.DEVICE: + self.lametric_apikey = validate_regex(apikey) + if not self.lametric_apikey: msg = 'An invalid LaMetric Device API Key ' \ '({}) was specified.'.format(apikey) self.logger.warning(msg) @@ -522,8 +590,7 @@ class NotifyLametric(NotifyBase): # Update header entries headers.update({ - 'X-Access-Token': self.secret, - 'Cache-Control': 'no-cache', + 'X-Access-Token': self.lametric_apikey, }) if self.sound: @@ -555,12 +622,14 @@ class NotifyLametric(NotifyBase): { "icon": icon, "text": body, + "index": 0, } ] } # Prepare our Cloud Notify URL - notify_url = self.cloud_notify_url.format(client_id=self.client_id) + notify_url = self.cloud_notify_url.format( + app_id=self.lametric_app_id, app_ver=self.lametric_app_ver) # Return request parameters return (notify_url, None, payload) @@ -646,6 +715,7 @@ class NotifyLametric(NotifyBase): 'User-Agent': self.app_id, 'Content-Type': 'application/json', 'Accept': 'application/json', + 'Cache-Control': 'no-cache', } # Depending on the mode, the payload is gathered by @@ -730,11 +800,12 @@ class NotifyLametric(NotifyBase): if self.mode == LametricMode.CLOUD: # Upstream/LaMetric App Return - return '{schema}://{client_id}@{secret}/?{params}'.format( + return '{schema}://{token}@{app_id}/{app_ver}/?{params}'.format( schema=self.protocol, - client_id=self.pprint(self.client_id, privacy, safe=''), - secret=self.pprint( - self.secret, privacy, mode=PrivacyMode.Secret, safe=''), + token=self.pprint( + self.lametric_app_access_token, privacy, safe=''), + app_id=self.pprint(self.lametric_app_id, privacy, safe=''), + app_ver=NotifyLametric.quote(self.lametric_app_ver, safe=''), params=NotifyLametric.urlencode(params)) # @@ -758,11 +829,11 @@ class NotifyLametric(NotifyBase): if self.user and self.password: auth = '{user}:{apikey}@'.format( user=NotifyLametric.quote(self.user, safe=''), - apikey=self.pprint(self.apikey, privacy, safe=''), + apikey=self.pprint(self.lametric_apikey, privacy, safe=''), ) else: # self.apikey is set auth = '{apikey}@'.format( - apikey=self.pprint(self.apikey, privacy, safe=''), + apikey=self.pprint(self.lametric_apikey, privacy, safe=''), ) # Local Return @@ -799,64 +870,91 @@ class NotifyLametric(NotifyBase): results['user'] = None # Priority Handling - if 'priority' in results['qsd'] and len(results['qsd']['priority']): - results['priority'] = results['qsd']['priority'].strip().lower() + if 'priority' in results['qsd'] and results['qsd']['priority']: + results['priority'] = NotifyLametric.unquote( + results['qsd']['priority'].strip().lower()) # Icon Type - if 'icon' in results['qsd'] and len(results['qsd']['icon']): - results['icon'] = results['qsd']['icon'].strip().lower() + if 'icon' in results['qsd'] and results['qsd']['icon']: + results['icon'] = NotifyLametric.unquote( + results['qsd']['icon'].strip().lower()) # Icon Type - if 'icon_type' in results['qsd'] and len(results['qsd']['icon_type']): - results['icon_type'] = results['qsd']['icon_type'].strip().lower() + if 'icon_type' in results['qsd'] and results['qsd']['icon_type']: + results['icon_type'] = NotifyLametric.unquote( + results['qsd']['icon_type'].strip().lower()) # Sound - if 'sound' in results['qsd'] and len(results['qsd']['sound']): - results['sound'] = results['qsd']['sound'].strip().lower() + if 'sound' in results['qsd'] and results['qsd']['sound']: + results['sound'] = NotifyLametric.unquote( + results['qsd']['sound'].strip().lower()) - # We can detect the mode based on the validity of the hostname - results['mode'] = LametricMode.DEVICE \ - if (is_hostname(results['host']) or - is_ipaddr(results['host'])) else LametricMode.CLOUD + # API Key (Device Mode) + if 'apikey' in results['qsd'] and results['qsd']['apikey']: + # Extract API Key from an argument + results['apikey'] = \ + NotifyLametric.unquote(results['qsd']['apikey']) - # Mode override - if 'mode' in results['qsd'] and len(results['qsd']['mode']): - results['mode'] = NotifyLametric.unquote(results['qsd']['mode']) + # App ID + if 'app' in results['qsd'] \ + and results['qsd']['app']: - # API Key (Device Mode) - if results['mode'] == LametricMode.DEVICE: - if 'apikey' in results['qsd'] and len(results['qsd']['apikey']): - # Extract API Key from an argument - results['apikey'] = \ - NotifyLametric.unquote(results['qsd']['apikey']) + # Extract the App ID from an argument + results['app_id'] = \ + NotifyLametric.unquote(results['qsd']['app']) - else: - results['apikey'] = \ - NotifyLametric.unquote(results['password']) + # App Version + if 'app_ver' in results['qsd'] \ + and results['qsd']['app_ver']: - elif results['mode'] == LametricMode.CLOUD: - # OAuth2 ID (Cloud Mode) - if 'oauth_id' in results['qsd'] \ - and len(results['qsd']['oauth_id']): + # Extract the App ID from an argument + results['app_ver'] = \ + NotifyLametric.unquote(results['qsd']['app_ver']) - # Extract the OAuth2 Key from an argument - results['client_id'] = \ - NotifyLametric.unquote(results['qsd']['oauth_id']) + if 'token' in results['qsd'] and results['qsd']['token']: + # Extract Application Access Token from an argument + results['app_token'] = \ + NotifyLametric.unquote(results['qsd']['token']) - else: - results['client_id'] = \ + # Mode override + if 'mode' in results['qsd'] and results['qsd']['mode']: + results['mode'] = NotifyLametric.unquote( + results['qsd']['mode'].strip().lower()) + else: + # We can try to detect the mode based on the validity of the + # hostname. We can also scan the validity of the Application + # Access token + # + # This isn't a surfire way to do things though; it's best to + # specify the mode= flag + results['mode'] = LametricMode.DEVICE \ + if ((is_hostname(results['host']) or + is_ipaddr(results['host'])) and + + # make sure password is not an Access Token + (results['password'] and not + LAMETRIC_IS_APP_TOKEN.match(results['password'])) and + + # Scan for app_ flags + next((f for f in results.keys() \ + if f.startswith('app_')), None) is None) \ + else LametricMode.CLOUD + + # Handle defaults if not set + if results['mode'] == LametricMode.DEVICE: + # Device Mode Defaults + if 'apikey' not in results: + results['apikey'] = \ NotifyLametric.unquote(results['password']) - # OAuth2 Secret (Cloud Mode) - if 'oauth_secret' in results['qsd'] and \ - len(results['qsd']['oauth_secret']): - # Extract the API Secret from an argument - results['secret'] = \ - NotifyLametric.unquote(results['qsd']['oauth_secret']) - - else: - results['secret'] = \ + else: + # CLOUD Mode Defaults + if 'app_id' not in results: + results['app_id'] = \ NotifyLametric.unquote(results['host']) + if 'app_token' not in results: + results['app_token'] = \ + NotifyLametric.unquote(results['password']) # Set cycles try: @@ -867,3 +965,38 @@ class NotifyLametric(NotifyBase): pass return results + + @staticmethod + def parse_native_url(url): + """ + Support + https://developer.lametric.com/api/v1/dev/\ + widget/update/com.lametric.{APP_ID}/1 + + https://developer.lametric.com/api/v1/dev/\ + widget/update/com.lametric.{APP_ID}/{APP_VER} + """ + + # If users do provide the Native URL they wll also want to add + # ?token={APP_ACCESS_TOKEN} to the parameters at the end or the + # URL will fail to load in later stages. + result = re.match( + r'^http(?P<secure>s)?://(?P<host>[^/]+)' + r'/api/(?P<api_ver>v[1-9]*[0-9]+)' + r'/dev/widget/update/' + r'com\.lametric\.(?P<app_id>[0-9a-z.-]{1,64})' + r'(/(?P<app_ver>[1-9][0-9]*))?/?' + r'(?P<params>\?.+)?$', url, re.I) + + if result: + return NotifyLametric.parse_url( + '{schema}://{app_id}{app_ver}/{params}'.format( + schema=NotifyLametric.secure_protocol + if result.group('secure') else NotifyLametric.protocol, + app_id=result.group('app_id'), + app_ver='/{}'.format(result.group('app_ver')) + if result.group('app_ver') else '', + params='' if not result.group('params') + else result.group('params'))) + + return None diff --git a/libs/apprise/plugins/NotifyMQTT.py b/libs/apprise/plugins/NotifyMQTT.py new file mode 100644 index 000000000..377107e68 --- /dev/null +++ b/libs/apprise/plugins/NotifyMQTT.py @@ -0,0 +1,536 @@ +# -*- coding: utf-8 -*- +# +# Copyright (C) 2021 Chris Caron <[email protected]> +# All rights reserved. +# +# This code is licensed under the MIT License. +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files(the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and / or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions : +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. + +# PAHO MQTT Documentation: +# https://www.eclipse.org/paho/index.php?page=clients/python/docs/index.php +# +# Looking at the PAHO MQTT Source can help shed light on what's going on too +# as their inline documentation is pretty good! +# https://github.com/eclipse/paho.mqtt.python\ +# /blob/master/src/paho/mqtt/client.py +import ssl +import re +import six +from time import sleep +from datetime import datetime +from os.path import isfile +from .NotifyBase import NotifyBase +from ..URLBase import PrivacyMode +from ..common import NotifyType +from ..utils import parse_list +from ..utils import parse_bool +from ..AppriseLocale import gettext_lazy as _ + +# Default our global support flag +NOTIFY_MQTT_SUPPORT_ENABLED = False + +if six.PY2: + # handle Python v2.7 suport + class ConnectionError(Exception): + pass + +try: + # 3rd party modules + import paho.mqtt.client as mqtt + + # We're good to go! + NOTIFY_MQTT_SUPPORT_ENABLED = True + + MQTT_PROTOCOL_MAP = { + # v3.1.1 + "311": mqtt.MQTTv311, + # v3.1 + "31": mqtt.MQTTv31, + # v5.0 + "5": mqtt.MQTTv5, + # v5.0 (alias) + "50": mqtt.MQTTv5, + } + +except ImportError: + # No problem; we just simply can't support this plugin because we're + # either using Linux, or simply do not have pywin32 installed. + MQTT_PROTOCOL_MAP = {} + +# A lookup map for relaying version to user +HUMAN_MQTT_PROTOCOL_MAP = { + "v3.1.1": "311", + "v3.1": "31", + "v5.0": "5", +} + + +class NotifyMQTT(NotifyBase): + """ + A wrapper for MQTT Notifications + """ + + # Set our global enabled flag + enabled = NOTIFY_MQTT_SUPPORT_ENABLED + + requirements = { + # Define our required packaging in order to work + 'packages_required': 'paho-mqtt' + } + + # The default descriptive name associated with the Notification + service_name = 'MQTT Notification' + + # The default protocol + protocol = 'mqtt' + + # Secure protocol + secure_protocol = 'mqtts' + + # A URL that takes you to the setup/help of the specific protocol + setup_url = 'https://github.com/caronc/apprise/wiki/Notify_mqtt' + + # MQTT does not have a title + title_maxlen = 0 + + # The maximum length a body can be set to + body_maxlen = 268435455 + + # Use a throttle; but it doesn't need to be so strict since most + # MQTT server hostings can handle the small bursts of packets and are + # locally hosted anyway + request_rate_per_sec = 0.5 + + # Port Defaults (unless otherwise specified) + mqtt_insecure_port = 1883 + + # The default secure port to use (if mqtts://) + mqtt_secure_port = 8883 + + # The default mqtt keepalive value + mqtt_keepalive = 30 + + # The default mqtt transport + mqtt_transport = "tcp" + + # The number of seconds to wait for a publish to occur at before + # checking to see if it's been sent yet. + mqtt_block_time_sec = 0.2 + + # Set the maximum number of messages with QoS>0 that can be part way + # through their network flow at once. + mqtt_inflight_messages = 200 + + # Taken from https://golang.org/src/crypto/x509/root_linux.go + CA_CERTIFICATE_FILE_LOCATIONS = [ + # Debian/Ubuntu/Gentoo etc. + "/etc/ssl/certs/ca-certificates.crt", + # Fedora/RHEL 6 + "/etc/pki/tls/certs/ca-bundle.crt", + # OpenSUSE + "/etc/ssl/ca-bundle.pem", + # OpenELEC + "/etc/pki/tls/cacert.pem", + # CentOS/RHEL 7 + "/etc/pki/ca-trust/extracted/pem/tls-ca-bundle.pem", + ] + + # Define object templates + templates = ( + '{schema}://{user}@{host}/{topic}', + '{schema}://{user}@{host}:{port}/{topic}', + '{schema}://{user}:{password}@{host}/{topic}', + '{schema}://{user}:{password}@{host}:{port}/{topic}', + ) + + template_tokens = dict(NotifyBase.template_tokens, **{ + 'host': { + 'name': _('Hostname'), + 'type': 'string', + 'required': True, + }, + 'port': { + 'name': _('Port'), + 'type': 'int', + 'min': 1, + 'max': 65535, + }, + 'user': { + 'name': _('User Name'), + 'type': 'string', + 'required': True, + }, + 'password': { + 'name': _('Password'), + 'type': 'string', + 'private': True, + 'required': True, + }, + 'topic': { + 'name': _('Target Queue'), + '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', + }, + 'qos': { + 'name': _('QOS'), + 'type': 'int', + 'default': 0, + 'min': 0, + 'max': 2, + }, + 'version': { + 'name': _('Version'), + 'type': 'choice:string', + 'values': HUMAN_MQTT_PROTOCOL_MAP, + 'default': "v3.1.1", + }, + 'client_id': { + 'name': _('Client ID'), + 'type': 'string', + }, + 'session': { + 'name': _('Use Session'), + 'type': 'bool', + 'default': False, + }, + }) + + def __init__(self, targets=None, version=None, qos=None, + client_id=None, session=None, **kwargs): + """ + Initialize MQTT Object + """ + + super(NotifyMQTT, self).__init__(**kwargs) + + # Initialize topics + self.topics = parse_list(targets) + + if version is None: + self.version = self.template_args['version']['default'] + else: + self.version = version + + # Save our client id if specified + self.client_id = client_id + + # Maintain our session (associated with our user id if set) + self.session = self.template_args['session']['default'] \ + if session is None or not self.client_id \ + else parse_bool(session) + + # Set up our Quality of Service (QoS) + try: + self.qos = self.template_args['qos']['default'] \ + if qos is None else int(qos) + + if self.qos < self.template_args['qos']['min'] \ + or self.qos > self.template_args['qos']['max']: + # Let error get handle on exceptio higher up + raise ValueError("") + + except (ValueError, TypeError): + msg = 'An invalid MQTT QOS ({}) was specified.'.format(qos) + self.logger.warning(msg) + raise TypeError(msg) + + if not self.port: + # Assign port (if not otherwise set) + self.port = self.mqtt_secure_port \ + if self.secure else self.mqtt_insecure_port + + self.ca_certs = None + if self.secure: + # verify SSL key or abort + self.ca_certs = next( + (cert for cert in self.CA_CERTIFICATE_FILE_LOCATIONS + if isfile(cert)), None) + + # Set up our MQTT Publisher + try: + # Get our protocol + self.mqtt_protocol = \ + MQTT_PROTOCOL_MAP[re.sub(r'[^0-9]+', '', self.version)] + + except (KeyError): + msg = 'An invalid MQTT Protocol version ' \ + '({}) was specified.'.format(version) + self.logger.warning(msg) + raise TypeError(msg) + + # Our MQTT Client Object + self.client = mqtt.Client( + client_id=self.client_id, + clean_session=not self.session, userdata=None, + protocol=self.mqtt_protocol, transport=self.mqtt_transport, + ) + + # Our maximum number of in-flight messages + self.client.max_inflight_messages_set(self.mqtt_inflight_messages) + + # Toggled to False once our connection has been established at least + # once + self.__initial_connect = True + + def send(self, body, title='', notify_type=NotifyType.INFO, **kwargs): + """ + Perform MQTT Notification + """ + + if len(self.topics) == 0: + # There were no services to notify + self.logger.warning('There were no MQTT topics to notify.') + return False + + # For logging: + url = '{host}:{port}'.format(host=self.host, port=self.port) + + try: + if self.__initial_connect: + # Our initial connection + if self.user: + self.client.username_pw_set( + self.user, password=self.password) + + if self.secure: + if self.ca_certs is None: + self.logger.warning( + 'MQTT Secure comunication can not be verified; ' + 'no local CA certificate file') + return False + + self.client.tls_set( + ca_certs=self.ca_certs, certfile=None, keyfile=None, + cert_reqs=ssl.CERT_REQUIRED, + tls_version=ssl.PROTOCOL_TLS, + ciphers=None) + + # Set our TLS Verify Flag + self.client.tls_insecure_set(self.verify_certificate) + + # Establish our connection + if self.client.connect( + self.host, port=self.port, + keepalive=self.mqtt_keepalive) \ + != mqtt.MQTT_ERR_SUCCESS: + self.logger.warning( + 'An MQTT connection could not be established for {}'. + format(url)) + return False + + # Start our client loop + self.client.loop_start() + + # Throttle our start otherwise the starting handshaking doesnt + # work. I'm not sure if this is a bug or not, but with qos=0, + # and without this sleep(), the messages randomly fails to be + # delivered. + sleep(0.01) + + # Toggle our flag since we never need to enter this area again + self.__initial_connect = False + + # Create a copy of the subreddits list + topics = list(self.topics) + + has_error = False + while len(topics) > 0 and not has_error: + # Retrieve our subreddit + topic = topics.pop() + + # For logging: + url = '{host}:{port}/{topic}'.format( + host=self.host, + port=self.port, + topic=topic) + + # Always call throttle before any remote server i/o is made + self.throttle() + + # handle a re-connection + if not self.client.is_connected() and \ + self.client.reconnect() != mqtt.MQTT_ERR_SUCCESS: + self.logger.warning( + 'An MQTT connection could not be sustained for {}'. + format(url)) + has_error = True + break + + # Some Debug Logging + self.logger.debug('MQTT POST URL: {} (cert_verify={})'.format( + url, self.verify_certificate)) + self.logger.debug('MQTT Payload: %s' % str(body)) + + result = self.client.publish( + topic, payload=body, qos=self.qos, retain=False) + + if result.rc != mqtt.MQTT_ERR_SUCCESS: + # Toggle our status + self.logger.warning( + 'An error (rc={}) occured when sending MQTT to {}'. + format(result.rc, url)) + has_error = True + break + + elif not result.is_published(): + self.logger.debug( + 'Blocking until MQTT payload is published...') + reference = datetime.now() + while not has_error and not result.is_published(): + # Throttle + sleep(self.mqtt_block_time_sec) + + # Our own throttle so we can abort eventually.... + elapsed = (datetime.now() - reference).total_seconds() + if elapsed >= self.socket_read_timeout: + self.logger.warning( + 'The MQTT message could not be delivered') + has_error = True + + # if we reach here; we're at the bottom of our loop + # we loop around and do the next topic now + + except ConnectionError as e: + self.logger.warning( + 'MQTT Connection Error received from {}'.format(url)) + self.logger.debug('Socket Exception: %s' % str(e)) + return False + + except ssl.CertificateError as e: + self.logger.warning( + 'MQTT SSL Certificate Error received from {}'.format(url)) + self.logger.debug('Socket Exception: %s' % str(e)) + return False + + except ValueError as e: + # ValueError's are thrown from publish() call if there is a problem + self.logger.warning( + 'MQTT Publishing error received: from {}'.format(url)) + self.logger.debug('Socket Exception: %s' % str(e)) + return False + + 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 = { + 'version': self.version, + 'qos': str(self.qos), + 'session': 'yes' if self.session else 'no', + } + + if self.client_id: + # Our client id is set if specified + params['client_id'] = self.client_id + + # 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=NotifyMQTT.quote(self.user, safe=''), + password=self.pprint( + self.password, privacy, mode=PrivacyMode.Secret, safe=''), + ) + elif self.user: + auth = '{user}@'.format( + user=NotifyMQTT.quote(self.user, safe=''), + ) + + default_port = self.mqtt_secure_port \ + if self.secure else self.mqtt_insecure_port + + 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( + [NotifyMQTT.quote(x, safe='/') for x in self.topics]), + params=NotifyMQTT.urlencode(params), + ) + + @staticmethod + def parse_url(url): + """ + There are no parameters nessisary for this protocol; simply having + windows:// is all you need. This function just makes sure that + is in place. + + """ + + results = NotifyBase.parse_url(url) + if not results: + # We're done early as we couldn't load the results + return results + + try: + # Acquire topic(s) + results['targets'] = parse_list( + NotifyMQTT.unquote(results['fullpath'].lstrip('/'))) + + except AttributeError: + # No 'fullpath' specified + results['targets'] = [] + + # The MQTT protocol version to use + if 'version' in results['qsd'] and len(results['qsd']['version']): + results['version'] = \ + NotifyMQTT.unquote(results['qsd']['version']) + + # The MQTT Client ID + if 'client_id' in results['qsd'] and len(results['qsd']['client_id']): + results['client_id'] = \ + NotifyMQTT.unquote(results['qsd']['client_id']) + + if 'session' in results['qsd'] and len(results['qsd']['session']): + results['session'] = parse_bool(results['qsd']['session']) + + # The MQTT Quality of Service to use + if 'qos' in results['qsd'] and len(results['qsd']['qos']): + results['qos'] = \ + NotifyMQTT.unquote(results['qsd']['qos']) + + # The 'to' makes it easier to use yaml configuration + if 'to' in results['qsd'] and len(results['qsd']['to']): + results['targets'].extend( + NotifyMQTT.parse_list(results['qsd']['to'])) + + # return results + return results diff --git a/libs/apprise/plugins/NotifyMSG91.py b/libs/apprise/plugins/NotifyMSG91.py index 68176fb93..f32ad8181 100644 --- a/libs/apprise/plugins/NotifyMSG91.py +++ b/libs/apprise/plugins/NotifyMSG91.py @@ -31,18 +31,15 @@ # Get details on the API used in this plugin here: # - https://world.msg91.com/apidoc/textsms/send-sms.php -import re import requests from .NotifyBase import NotifyBase from ..common import NotifyType -from ..utils import parse_list +from ..utils import is_phone_no +from ..utils import parse_phone_no from ..utils import validate_regex from ..AppriseLocale import gettext_lazy as _ -# Some Phone Number Detection -IS_PHONE_NO = re.compile(r'^\+?(?P<phone>[0-9\s)(+-]+)\s*$') - class MSG91Route(object): """ @@ -207,33 +204,18 @@ class NotifyMSG91(NotifyBase): # Parse our targets self.targets = list() - for target in parse_list(targets): + for target in parse_phone_no(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) + result = is_phone_no(target) + if not result: + self.logger.warning( + 'Dropped invalid phone # ' + '({}) specified.'.format(target), + ) continue - self.logger.warning( - 'Dropped invalid phone # ' - '({}) specified.'.format(target), - ) - - if not self.targets: - # We have a bot token and no target(s) to message - msg = 'No MSG91 targets to notify.' - self.logger.warning(msg) - raise TypeError(msg) + # store valid phone number + self.targets.append(result['full']) return @@ -242,6 +224,11 @@ class NotifyMSG91(NotifyBase): Perform MSG91 Notification """ + if len(self.targets) == 0: + # There were no services to notify + self.logger.warning('There were no MSG91 targets to notify.') + return False + # Prepare our headers headers = { 'User-Agent': self.app_id, @@ -365,6 +352,6 @@ class NotifyMSG91(NotifyBase): # The 'to' makes it easier to use yaml configuration if 'to' in results['qsd'] and len(results['qsd']['to']): results['targets'] += \ - NotifyMSG91.parse_list(results['qsd']['to']) + NotifyMSG91.parse_phone_no(results['qsd']['to']) return results diff --git a/libs/apprise/plugins/NotifyMSTeams.py b/libs/apprise/plugins/NotifyMSTeams.py index b12c5e450..e69247124 100644 --- a/libs/apprise/plugins/NotifyMSTeams.py +++ b/libs/apprise/plugins/NotifyMSTeams.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # -# Copyright (C) 2019 Chris Caron <[email protected]> +# Copyright (C) 2020 Chris Caron <[email protected]> # All rights reserved. # # This code is licensed under the MIT License. @@ -43,26 +43,39 @@ # # When you've completed this, it will generate you a (webhook) URL that # looks like: -# https://outlook.office.com/webhook/ \ +# https://team-name.webhook.office.com/webhookb2/ \ # abcdefgf8-2f4b-4eca-8f61-225c83db1967@abcdefg2-5a99-4849-8efc-\ # c9e78d28e57d/IncomingWebhook/291289f63a8abd3593e834af4d79f9fe/\ # a2329f43-0ffb-46ab-948b-c9abdad9d643 # # Yes... The URL is that big... But it looks like this (greatly simplified): +# https://TEAM-NAME.webhook.office.com/webhookb2/ABCD/IncomingWebhook/DEFG/HIJK +# ^ ^ ^ ^ +# | | | | +# These are important <--------------------------^--------------------^----^ +# + +# The Legacy format didn't have the team name identified and reads 'outlook' +# While this still works, consider that Microsoft will be dropping support +# for this soon, so you may need to update your IncomingWebhook. Here is +# what a legacy URL looked like: # https://outlook.office.com/webhook/ABCD/IncomingWebhook/DEFG/HIJK -# ^ ^ ^ +# ^ ^ ^ ^ +# | | | | +# legacy team reference: 'outlook' | | | # | | | # These are important <--------------^--------------------^----^ # + # You'll notice that the first token is actually 2 separated by an @ symbol # But lets just ignore that and assume it's one great big token instead. # -# These 3 tokens is what you'll need to build your URL with: -# msteams://ABCD/DEFG/HIJK +# These 3 tokens need to be placed in the URL after the Team +# msteams://TEAM/ABCD/DEFG/HIJK # import re import requests -from json import dumps +import json from .NotifyBase import NotifyBase from ..common import NotifyImageSize @@ -70,11 +83,17 @@ from ..common import NotifyType from ..common import NotifyFormat from ..utils import parse_bool from ..utils import validate_regex +from ..utils import apply_template +from ..utils import TemplateType +from ..AppriseAttachment import AppriseAttachment from ..AppriseLocale import gettext_lazy as _ -# Used to prepare our UUID regex matching -UUID4_RE = \ - r'[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}' +try: + from json.decoder import JSONDecodeError + +except ImportError: + # Python v2.7 Backwards Compatibility support + JSONDecodeError = ValueError class NotifyMSTeams(NotifyBase): @@ -95,7 +114,12 @@ class NotifyMSTeams(NotifyBase): setup_url = 'https://github.com/caronc/apprise/wiki/Notify_msteams' # MSTeams uses the http protocol with JSON requests - notify_url = 'https://outlook.office.com/webhook' + notify_url_v1 = 'https://outlook.office.com/webhook/' \ + '{token_a}/IncomingWebhook/{token_b}/{token_c}' + + # New MSTeams webhook (as of April 11th, 2021) + notify_url_v2 = 'https://{team}.webhook.office.com/webhookb2/' \ + '{token_a}/IncomingWebhook/{token_b}/{token_c}' # Allows the user to specify the NotifyImageSize object image_size = NotifyImageSize.XY_72 @@ -106,13 +130,28 @@ class NotifyMSTeams(NotifyBase): # Default Notification Format notify_format = NotifyFormat.MARKDOWN + # There is no reason we should exceed 35KB when reading in a JSON file. + # If it is more than this, then it is not accepted + max_msteams_template_size = 35000 + # Define object templates templates = ( - '{schema}://{token_a}/{token_b}{token_c}', + # New required format + '{schema}://{team}/{token_a}/{token_b}/{token_c}', + + # Deprecated + '{schema}://{token_a}/{token_b}/{token_c}', ) # Define our template tokens template_tokens = dict(NotifyBase.template_tokens, **{ + # The Microsoft Team Name + 'team': { + 'name': _('Team Name'), + 'type': 'string', + 'required': True, + 'regex': (r'^[A-Z0-9_-]+$', 'i'), + }, # Token required as part of the API request # /AAAAAAAAA@AAAAAAAAA/........./......... 'token_a': { @@ -120,7 +159,7 @@ class NotifyMSTeams(NotifyBase): 'type': 'string', 'private': True, 'required': True, - 'regex': (r'^{}@{}$'.format(UUID4_RE, UUID4_RE), 'i'), + 'regex': (r'^[A-Z0-9-]+@[A-Z0-9-]+$', 'i'), }, # Token required as part of the API request # /................../BBBBBBBBB/.......... @@ -129,7 +168,7 @@ class NotifyMSTeams(NotifyBase): 'type': 'string', 'private': True, 'required': True, - 'regex': (r'^[A-Za-z0-9]{32}$', 'i'), + 'regex': (r'^[a-z0-9]+$', 'i'), }, # Token required as part of the API request # /........./........./CCCCCCCCCCCCCCCCCCCCCCCC @@ -138,7 +177,7 @@ class NotifyMSTeams(NotifyBase): 'type': 'string', 'private': True, 'required': True, - 'regex': (r'^{}$'.format(UUID4_RE), 'i'), + 'regex': (r'^[a-z0-9-]+$', 'i'), }, }) @@ -150,15 +189,67 @@ class NotifyMSTeams(NotifyBase): 'default': False, 'map_to': 'include_image', }, + 'version': { + 'name': _('Version'), + 'type': 'choice:int', + 'values': (1, 2), + 'default': 2, + }, + 'template': { + 'name': _('Template Path'), + 'type': 'string', + 'private': True, + }, }) - def __init__(self, token_a, token_b, token_c, include_image=True, - **kwargs): + # Define our token control + template_kwargs = { + 'tokens': { + 'name': _('Template Tokens'), + 'prefix': ':', + }, + } + + def __init__(self, token_a, token_b, token_c, team=None, version=None, + include_image=True, template=None, tokens=None, **kwargs): """ Initialize Microsoft Teams Object + + You can optional specify a template and identify arguments you + wish to populate your template with when posting. Some reserved + template arguments that can not be over-ridden are: + `body`, `title`, and `type`. """ super(NotifyMSTeams, self).__init__(**kwargs) + try: + self.version = int(version) + + except TypeError: + # None was specified... take on default + self.version = self.template_args['version']['default'] + + except ValueError: + # invalid content was provided; let this get caught in the next + # validation check for the version + self.version = None + + if self.version not in self.template_args['version']['values']: + msg = 'An invalid MSTeams Version ' \ + '({}) was specified.'.format(version) + self.logger.warning(msg) + raise TypeError(msg) + + self.team = validate_regex(team) + if not self.team: + NotifyBase.logger.deprecate( + "Apprise requires you to identify your Microsoft Team name as " + "part of the URL. e.g.: " + "msteams://TEAM-NAME/{token_a}/{token_b}/{token_c}") + + # Fallback + self.team = 'outlook' + self.token_a = validate_regex( token_a, *self.template_tokens['token_a']['regex']) if not self.token_a: @@ -186,8 +277,120 @@ class NotifyMSTeams(NotifyBase): # Place a thumbnail image inline with the message body self.include_image = include_image + # Our template object is just an AppriseAttachment object + self.template = AppriseAttachment(asset=self.asset) + if template: + # Add our definition to our template + self.template.add(template) + # Enforce maximum file size + self.template[0].max_file_size = self.max_msteams_template_size + + # Template functionality + self.tokens = {} + if isinstance(tokens, dict): + self.tokens.update(tokens) + + elif tokens: + msg = 'The specified MSTeams Template Tokens ' \ + '({}) are not identified as a dictionary.'.format(tokens) + self.logger.warning(msg) + raise TypeError(msg) + + # else: NoneType - this is okay return + def gen_payload(self, body, title='', notify_type=NotifyType.INFO, + **kwargs): + """ + This function generates our payload whether it be the generic one + Apprise generates by default, or one provided by a specified + external template. + """ + + # Acquire our to-be footer icon if configured to do so + image_url = None if not self.include_image \ + else self.image_url(notify_type) + + if not self.template: + # By default we use a generic working payload if there was + # no template specified + payload = { + "@type": "MessageCard", + "@context": "https://schema.org/extensions", + "summary": self.app_desc, + "themeColor": self.color(notify_type), + "sections": [ + { + "activityImage": None, + "activityTitle": title, + "text": body, + }, + ] + } + + if image_url: + payload['sections'][0]['activityImage'] = image_url + + return payload + + # If our code reaches here, then we generate ourselves the payload + template = self.template[0] + if not template: + # We could not access the attachment + self.logger.error( + 'Could not access MSTeam template {}.'.format( + template.url(privacy=True))) + return False + + # Take a copy of our token dictionary + tokens = self.tokens.copy() + + # Apply some defaults template values + tokens['app_body'] = body + tokens['app_title'] = title + tokens['app_type'] = notify_type + tokens['app_id'] = self.app_id + tokens['app_desc'] = self.app_desc + tokens['app_color'] = self.color(notify_type) + tokens['app_image_url'] = image_url + tokens['app_url'] = self.app_url + + # Enforce Application mode + tokens['app_mode'] = TemplateType.JSON + + try: + with open(template.path, 'r') as fp: + content = json.loads(apply_template(fp.read(), **tokens)) + + except (OSError, IOError): + self.logger.error( + 'MSTeam template {} could not be read.'.format( + template.url(privacy=True))) + return None + + except JSONDecodeError as e: + self.logger.error( + 'MSTeam template {} contains invalid JSON.'.format( + template.url(privacy=True))) + self.logger.debug('JSONDecodeError: {}'.format(e)) + return None + + # Load our JSON data (if valid) + has_error = False + if '@type' not in content: + self.logger.error( + 'MSTeam template {} is missing @type kwarg.'.format( + template.url(privacy=True))) + has_error = True + + if '@context' not in content: + self.logger.error( + 'MSTeam template {} is missing @context kwarg.'.format( + template.url(privacy=True))) + has_error = True + + return content if not has_error else None + def send(self, body, title='', notify_type=NotifyType.INFO, **kwargs): """ Perform Microsoft Teams Notification @@ -198,37 +401,27 @@ class NotifyMSTeams(NotifyBase): 'Content-Type': 'application/json', } - url = '%s/%s/IncomingWebhook/%s/%s' % ( - self.notify_url, - self.token_a, - self.token_b, - self.token_c, - ) - - # Prepare our payload - payload = { - "@type": "MessageCard", - "@context": "https://schema.org/extensions", - "summary": self.app_desc, - "themeColor": self.color(notify_type), - "sections": [ - { - "activityImage": None, - "activityTitle": title, - "text": body, - }, - ] - } - - # Acquire our to-be footer icon if configured to do so - image_url = None if not self.include_image \ - else self.image_url(notify_type) - - if image_url: - payload['sections'][0]['activityImage'] = image_url + notify_url = self.notify_url_v2.format( + team=self.team, + token_a=self.token_a, + token_b=self.token_b, + token_c=self.token_c, + ) if self.version > 1 else \ + self.notify_url_v1.format( + token_a=self.token_a, + token_b=self.token_b, + token_c=self.token_c) + + # Generate our payload if it's possible + payload = self.gen_payload( + body=body, title=title, notify_type=notify_type, **kwargs) + if not payload: + # No need to present a reason; that will come from the + # gen_payload() function itself + return False self.logger.debug('MSTeams POST URL: %s (cert_verify=%r)' % ( - url, self.verify_certificate, + notify_url, self.verify_certificate, )) self.logger.debug('MSTeams Payload: %s' % str(payload)) @@ -236,8 +429,8 @@ class NotifyMSTeams(NotifyBase): self.throttle() try: r = requests.post( - url, - data=dumps(payload), + notify_url, + data=json.dumps(payload), headers=headers, verify=self.verify_certificate, timeout=self.request_timeout, @@ -283,17 +476,38 @@ class NotifyMSTeams(NotifyBase): 'image': 'yes' if self.include_image else 'no', } + if self.version != self.template_args['version']['default']: + params['version'] = str(self.version) + + if self.template: + params['template'] = NotifyMSTeams.quote( + self.template[0].url(), safe='') + # Extend our parameters params.update(self.url_parameters(privacy=privacy, *args, **kwargs)) - - return '{schema}://{token_a}/{token_b}/{token_c}/'\ - '?{params}'.format( - schema=self.secure_protocol, - token_a=self.pprint(self.token_a, privacy, safe=''), - token_b=self.pprint(self.token_b, privacy, safe=''), - token_c=self.pprint(self.token_c, privacy, safe=''), - params=NotifyMSTeams.urlencode(params), - ) + # Store any template entries if specified + params.update({':{}'.format(k): v for k, v in self.tokens.items()}) + + if self.version > 1: + return '{schema}://{team}/{token_a}/{token_b}/{token_c}/'\ + '?{params}'.format( + schema=self.secure_protocol, + team=NotifyMSTeams.quote(self.team, safe=''), + token_a=self.pprint(self.token_a, privacy, safe=''), + token_b=self.pprint(self.token_b, privacy, safe=''), + token_c=self.pprint(self.token_c, privacy, safe=''), + params=NotifyMSTeams.urlencode(params), + ) + + else: # Version 1 + return '{schema}://{token_a}/{token_b}/{token_c}/'\ + '?{params}'.format( + schema=self.secure_protocol, + token_a=self.pprint(self.token_a, privacy, safe='@'), + token_b=self.pprint(self.token_b, privacy, safe=''), + token_c=self.pprint(self.token_c, privacy, safe=''), + params=NotifyMSTeams.urlencode(params), + ) @staticmethod def parse_url(url): @@ -302,6 +516,7 @@ class NotifyMSTeams(NotifyBase): 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 @@ -310,6 +525,7 @@ class NotifyMSTeams(NotifyBase): # Get unquoted entries entries = NotifyMSTeams.split_path(results['fullpath']) + # Deprecated mode (backwards compatibility) if results.get('user'): # If a user was found, it's because it's still part of the first # token, so we concatinate them @@ -319,42 +535,62 @@ class NotifyMSTeams(NotifyBase): ) else: - # The first token is stored in the hostname - results['token_a'] = NotifyMSTeams.unquote(results['host']) + # Get the Team from the hostname + results['team'] = NotifyMSTeams.unquote(results['host']) - # Now fetch the remaining tokens - try: - results['token_b'] = entries.pop(0) + # Get the token from the path + results['token_a'] = None if not entries \ + else NotifyMSTeams.unquote(entries.pop(0)) - except IndexError: - # We're done - results['token_b'] = None - - try: - results['token_c'] = entries.pop(0) - - except IndexError: - # We're done - results['token_c'] = None + results['token_b'] = None if not entries \ + else NotifyMSTeams.unquote(entries.pop(0)) + results['token_c'] = None if not entries \ + else NotifyMSTeams.unquote(entries.pop(0)) # Get Image results['include_image'] = \ parse_bool(results['qsd'].get('image', True)) + # Get Team name if defined + if 'team' in results['qsd'] and results['qsd']['team']: + results['team'] = \ + NotifyMSTeams.unquote(results['qsd']['team']) + + # Template Handling + if 'template' in results['qsd'] and results['qsd']['template']: + results['template'] = \ + NotifyMSTeams.unquote(results['qsd']['template']) + + # Override version if defined + if 'version' in results['qsd'] and results['qsd']['version']: + results['version'] = \ + NotifyMSTeams.unquote(results['qsd']['version']) + + else: + # Set our version if not otherwise set + results['version'] = 1 if not results.get('team') else 2 + + # Store our tokens + results['tokens'] = results['qsd:'] + return results @staticmethod def parse_native_url(url): """ - Support: + Legacy Support: https://outlook.office.com/webhook/ABCD/IncomingWebhook/DEFG/HIJK + + New Hook Support: + https://team-name.office.com/webhook/ABCD/IncomingWebhook/DEFG/HIJK """ # We don't need to do incredibly details token matching as the purpose # of this is just to detect that were dealing with an msteams url # token parsing will occur once we initialize the function result = re.match( - r'^https?://outlook\.office\.com/webhook/' + r'^https?://(?P<team>[^.]+)(?P<v2a>\.webhook)?\.office\.com/' + r'webhook(?P<v2b>b2)?/' r'(?P<token_a>[A-Z0-9-]+@[A-Z0-9-]+)/' r'IncomingWebhook/' r'(?P<token_b>[A-Z0-9]+)/' @@ -362,13 +598,28 @@ class NotifyMSTeams(NotifyBase): r'(?P<params>\?.+)?$', url, re.I) if result: - return NotifyMSTeams.parse_url( - '{schema}://{token_a}/{token_b}/{token_c}/{params}'.format( - schema=NotifyMSTeams.secure_protocol, - token_a=result.group('token_a'), - token_b=result.group('token_b'), - token_c=result.group('token_c'), - params='' if not result.group('params') - else result.group('params'))) - + if result.group('v2a'): + # Version 2 URL + return NotifyMSTeams.parse_url( + '{schema}://{team}/{token_a}/{token_b}/{token_c}' + '/{params}'.format( + schema=NotifyMSTeams.secure_protocol, + team=result.group('team'), + token_a=result.group('token_a'), + token_b=result.group('token_b'), + token_c=result.group('token_c'), + params='' if not result.group('params') + else result.group('params'))) + else: + # Version 1 URLs + # team is also set to 'outlook' in this case + return NotifyMSTeams.parse_url( + '{schema}://{token_a}/{token_b}/{token_c}' + '/{params}'.format( + schema=NotifyMSTeams.secure_protocol, + token_a=result.group('token_a'), + token_b=result.group('token_b'), + token_c=result.group('token_c'), + params='' if not result.group('params') + else result.group('params'))) return None diff --git a/libs/apprise/plugins/NotifyMacOSX.py b/libs/apprise/plugins/NotifyMacOSX.py index d1160c37e..7c9e289cf 100644 --- a/libs/apprise/plugins/NotifyMacOSX.py +++ b/libs/apprise/plugins/NotifyMacOSX.py @@ -36,6 +36,19 @@ from ..common import NotifyType from ..utils import parse_bool from ..AppriseLocale import gettext_lazy as _ +# Default our global support flag +NOTIFY_MACOSX_SUPPORT_ENABLED = False + +if platform.system() == 'Darwin': + # Check this is Mac OS X 10.8, or higher + major, minor = platform.mac_ver()[0].split('.')[:2] + + # Toggle our enabled flag if verion is correct and executable + # found. This is done in such a way to provide verbosity to the + # end user so they know why it may or may not work for them. + NOTIFY_MACOSX_SUPPORT_ENABLED = \ + (int(major) > 10 or (int(major) == 10 and int(minor) >= 8)) + class NotifyMacOSX(NotifyBase): """ @@ -44,8 +57,22 @@ class NotifyMacOSX(NotifyBase): Source: https://github.com/julienXX/terminal-notifier """ + # Set our global enabled flag + enabled = NOTIFY_MACOSX_SUPPORT_ENABLED + + requirements = { + # Define our required packaging in order to work + 'details': _( + 'Only works with Mac OS X 10.8 and higher. Additionally ' + ' requires that /usr/local/bin/terminal-notifier is locally ' + 'accessible.') + } + # The default descriptive name associated with the Notification - service_name = 'MacOSX Notification' + service_name = _('MacOSX Notification') + + # The services URL + service_url = 'https://github.com/julienXX/terminal-notifier' # The default protocol protocol = 'macosx' @@ -100,31 +127,8 @@ class NotifyMacOSX(NotifyBase): # or not. self.include_image = include_image - self._enabled = False - if platform.system() == 'Darwin': - # Check this is Mac OS X 10.8, or higher - major, minor = platform.mac_ver()[0].split('.')[:2] - - # Toggle our _enabled flag if verion is correct and executable - # found. This is done in such a way to provide verbosity to the - # end user so they know why it may or may not work for them. - if not (int(major) > 10 or (int(major) == 10 and int(minor) >= 8)): - self.logger.warning( - "MacOSX Notifications require your OS to be at least " - "v10.8 (detected {}.{})".format(major, minor)) - - elif not os.access(self.notify_path, os.X_OK): - self.logger.warning( - "MacOSX Notifications require '{}' to be in place." - .format(self.notify_path)) - - else: - # We're good to go - self._enabled = True - # Set sound object (no q/a for now) self.sound = sound - return def send(self, body, title='', notify_type=NotifyType.INFO, **kwargs): @@ -132,9 +136,10 @@ class NotifyMacOSX(NotifyBase): Perform MacOSX Notification """ - if not self._enabled: + if not os.access(self.notify_path, os.X_OK): self.logger.warning( - "MacOSX Notifications are not supported by this system.") + "MacOSX Notifications require '{}' to be in place." + .format(self.notify_path)) return False # Start with our notification path @@ -160,6 +165,9 @@ class NotifyMacOSX(NotifyBase): # Always call throttle before any remote server i/o is made self.throttle() + # Capture some output for helpful debugging later on + self.logger.debug('MacOSX CMD: {}'.format(' '.join(cmd))) + # Send our notification output = subprocess.Popen( cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT) diff --git a/libs/apprise/plugins/NotifyMailgun.py b/libs/apprise/plugins/NotifyMailgun.py index e876a5bda..49298562f 100644 --- a/libs/apprise/plugins/NotifyMailgun.py +++ b/libs/apprise/plugins/NotifyMailgun.py @@ -52,10 +52,12 @@ # then it will also become the 'to' address as well. # import requests - +from email.utils import formataddr from .NotifyBase import NotifyBase from ..common import NotifyType -from ..utils import parse_list +from ..common import NotifyFormat +from ..utils import parse_emails +from ..utils import parse_bool from ..utils import is_email from ..utils import validate_regex from ..AppriseLocale import gettext_lazy as _ @@ -111,9 +113,16 @@ class NotifyMailgun(NotifyBase): # A URL that takes you to the setup/help of the specific protocol setup_url = 'https://github.com/caronc/apprise/wiki/Notify_mailgun' + # Default Notify Format + notify_format = NotifyFormat.HTML + # The default region to use if one isn't otherwise specified mailgun_default_region = MailgunRegion.US + # The maximum amount of emails that can reside within a single + # batch transfer + default_batch_size = 2000 + # Define object templates templates = ( '{schema}://{user}@{host}:{apikey}/', @@ -161,9 +170,35 @@ class NotifyMailgun(NotifyBase): 'to': { 'alias_of': 'targets', }, + 'cc': { + 'name': _('Carbon Copy'), + 'type': 'list:string', + }, + 'bcc': { + 'name': _('Blind Carbon Copy'), + 'type': 'list:string', + }, + 'batch': { + 'name': _('Batch Mode'), + 'type': 'bool', + 'default': False, + }, }) - def __init__(self, apikey, targets, from_name=None, region_name=None, + # Define any kwargs we're using + template_kwargs = { + 'headers': { + 'name': _('Email Header'), + 'prefix': '+', + }, + 'tokens': { + 'name': _('Template Tokens'), + 'prefix': ':', + }, + } + + def __init__(self, apikey, targets, cc=None, bcc=None, from_name=None, + region_name=None, headers=None, tokens=None, batch=False, **kwargs): """ Initialize Mailgun Object @@ -184,8 +219,30 @@ class NotifyMailgun(NotifyBase): self.logger.warning(msg) raise TypeError(msg) - # Parse our targets - self.targets = parse_list(targets) + # Acquire Email 'To' + self.targets = list() + + # Acquire Carbon Copies + self.cc = set() + + # Acquire Blind Carbon Copies + self.bcc = set() + + # For tracking our email -> name lookups + self.names = {} + + self.headers = {} + if headers: + # Store our extra headers + self.headers.update(headers) + + self.tokens = {} + if tokens: + # Store our template tokens + self.tokens.update(tokens) + + # Prepare Batch Mode Flag + self.batch = batch # Store our region try: @@ -214,29 +271,146 @@ class NotifyMailgun(NotifyBase): self.logger.warning(msg) raise TypeError(msg) - def send(self, body, title='', notify_type=NotifyType.INFO, **kwargs): + if targets: + # Validate recipients (to:) and drop bad ones: + for recipient in parse_emails(targets): + result = is_email(recipient) + if result: + self.targets.append( + (result['name'] if result['name'] else False, + result['full_email'])) + continue + + self.logger.warning( + 'Dropped invalid To email ' + '({}) specified.'.format(recipient), + ) + + else: + # If our target email list is empty we want to add ourselves to it + self.targets.append( + (self.from_name if self.from_name else False, self.from_addr)) + + # Validate recipients (cc:) and drop bad ones: + for recipient in parse_emails(cc): + email = is_email(recipient) + if email: + self.cc.add(email['full_email']) + + # Index our name (if one exists) + self.names[email['full_email']] = \ + email['name'] if email['name'] else False + continue + + self.logger.warning( + 'Dropped invalid Carbon Copy email ' + '({}) specified.'.format(recipient), + ) + + # Validate recipients (bcc:) and drop bad ones: + for recipient in parse_emails(bcc): + email = is_email(recipient) + if email: + self.bcc.add(email['full_email']) + + # Index our name (if one exists) + self.names[email['full_email']] = \ + email['name'] if email['name'] else False + continue + + self.logger.warning( + 'Dropped invalid Blind Carbon Copy email ' + '({}) specified.'.format(recipient), + ) + + def send(self, body, title='', notify_type=NotifyType.INFO, attach=None, + **kwargs): """ Perform Mailgun Notification """ + if not self.targets: + # There is no one to email; we're done + self.logger.warning( + 'There are no Email recipients to notify') + return False + # error tracking (used for function return) has_error = False + # Send in batches if identified to do so + batch_size = 1 if not self.batch else self.default_batch_size + # Prepare our headers headers = { 'User-Agent': self.app_id, 'Accept': 'application/json', } + # Track our potential files + files = {} + + if attach: + for idx, attachment in enumerate(attach): + # 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 + + self.logger.debug( + 'Preparing Mailgun attachment {}'.format( + attachment.url(privacy=True))) + try: + files['attachment[{}]'.format(idx)] = \ + (attachment.name, open(attachment.path, 'rb')) + + except (OSError, IOError) as e: + self.logger.warning( + 'An I/O error occurred while opening {}.'.format( + attachment.name if attachment + else 'attachment')) + self.logger.debug('I/O Exception: %s' % str(e)) + + # tidy up any open files before we make our early + # return + for entry in files.values(): + self.logger.trace( + 'Closing attachment {}'.format(entry[0])) + entry[1].close() + + return False + + try: + reply_to = formataddr( + (self.from_name if self.from_name else False, + self.from_addr), charset='utf-8') + + except TypeError: + # Python v2.x Support (no charset keyword) + # Format our cc addresses to support the Name field + reply_to = formataddr( + (self.from_name if self.from_name else False, + self.from_addr)) + # Prepare our payload payload = { - 'from': '{name} <{addr}>'.format( - name=self.app_id if not self.from_name else self.from_name, - addr=self.from_addr), + # pass skip-verification switch upstream too + 'o:skip-verification': not self.verify_certificate, + + # Base payload options + 'from': reply_to, 'subject': title, - 'text': body, } + if self.notify_format == NotifyFormat.HTML: + payload['html'] = body + + else: + payload['text'] = body + # Prepare our URL as it's based on our hostname url = '{}{}/messages'.format( MAILGUN_API_LOOKUP[self.region_name], self.host) @@ -244,22 +418,106 @@ class NotifyMailgun(NotifyBase): # Create a copy of the targets list emails = list(self.targets) - if len(emails) == 0: - # No email specified; use the from - emails.append(self.from_addr) + for index in range(0, len(emails), batch_size): + # Initialize our cc list + cc = (self.cc - self.bcc) + + # Initialize our bcc list + bcc = set(self.bcc) - while len(emails): - # Get our email to notify - email = emails.pop(0) + # Initialize our to list + to = list() - # Prepare our user - payload['to'] = '{} <{}>'.format(email, email) + # Ensure we're pointed to the head of the attachment; this doesn't + # do much for the first iteration through this loop as we're + # already pointing there..., but it allows us to re-use the + # attachment over and over again without closing and then + # re-opening the same file again and again + for entry in files.values(): + try: + self.logger.trace( + 'Seeking to head of attachment {}'.format(entry[0])) + entry[1].seek(0) + + except (OSError, IOError) as e: + self.logger.warning( + 'An I/O error occurred seeking to head of attachment ' + '{}.'.format(entry[0])) + self.logger.debug('I/O Exception: %s' % str(e)) + + # tidy up any open files before we make our early + # return + for entry in files.values(): + self.logger.trace( + 'Closing attachment {}'.format(entry[0])) + entry[1].close() + + return False + + for to_addr in self.targets[index:index + batch_size]: + # Strip target out of cc list if in To + cc = (cc - set([to_addr[1]])) + + # Strip target out of bcc list if in To + bcc = (bcc - set([to_addr[1]])) + + try: + # Prepare our to + to.append(formataddr(to_addr, charset='utf-8')) + + except TypeError: + # Python v2.x Support (no charset keyword) + # Format our cc addresses to support the Name field + + # Prepare our to + to.append(formataddr(to_addr)) + + # Prepare our To + payload['to'] = ','.join(to) + + if cc: + try: + # Format our cc addresses to support the Name field + payload['cc'] = ','.join([formataddr( + (self.names.get(addr, False), addr), charset='utf-8') + for addr in cc]) + + except TypeError: + # Python v2.x Support (no charset keyword) + # Format our cc addresses to support the Name field + payload['cc'] = ','.join([formataddr( # pragma: no branch + (self.names.get(addr, False), addr)) + for addr in cc]) + + # Format our bcc addresses to support the Name field + if bcc: + payload['bcc'] = ','.join(bcc) + + # Store our token entries; users can reference these as %value% + # in their email message. + if self.tokens: + payload.update( + {'v:{}'.format(k): v for k, v in self.tokens.items()}) + + # Store our header entries if defined into the payload + # in their payload + if self.headers: + payload.update( + {'h:{}'.format(k): v for k, v in self.headers.items()}) # Some Debug Logging self.logger.debug('Mailgun POST URL: {} (cert_verify={})'.format( url, self.verify_certificate)) self.logger.debug('Mailgun Payload: {}' .format(payload)) + # For logging output of success and errors; we get a head count + # of our outbound details: + verbose_dest = ', '.join( + [x[1] for x in self.targets[index:index + batch_size]]) \ + if len(self.targets[index:index + batch_size]) <= 3 \ + else '{} recipients'.format( + len(self.targets[index:index + batch_size])) + # Always call throttle before any remote server i/o is made self.throttle() try: @@ -268,6 +526,7 @@ class NotifyMailgun(NotifyBase): auth=("api", self.apikey), data=payload, headers=headers, + files=None if not files else files, verify=self.verify_certificate, timeout=self.request_timeout, ) @@ -276,12 +535,12 @@ class NotifyMailgun(NotifyBase): # We had a problem status_str = \ NotifyBase.http_response_code_lookup( - r.status_code, MAILGUN_API_LOOKUP) + r.status_code, MAILGUN_HTTP_ERROR_MAP) self.logger.warning( 'Failed to send Mailgun notification to {}: ' '{}{}error={}.'.format( - email, + verbose_dest, status_str, ', ' if status_str else '', r.status_code)) @@ -295,12 +554,13 @@ class NotifyMailgun(NotifyBase): else: self.logger.info( - 'Sent Mailgun notification to {}.'.format(email)) + 'Sent Mailgun notification to {}.'.format( + verbose_dest)) except requests.RequestException as e: self.logger.warning( 'A Connection error occurred sending Mailgun:%s ' % ( - email) + 'notification.' + verbose_dest) + 'notification.' ) self.logger.debug('Socket Exception: %s' % str(e)) @@ -308,6 +568,21 @@ class NotifyMailgun(NotifyBase): has_error = True continue + except (OSError, IOError) as e: + self.logger.warning( + 'An I/O error occurred while reading attachments') + self.logger.debug('I/O Exception: %s' % str(e)) + + # Mark our failure + has_error = True + continue + + # Close any potential attachments that are still open + for entry in files.values(): + self.logger.trace( + 'Closing attachment {}'.format(entry[0])) + entry[1].close() + return not has_error def url(self, privacy=False, *args, **kwargs): @@ -318,8 +593,15 @@ class NotifyMailgun(NotifyBase): # Define any URL parameters params = { 'region': self.region_name, + 'batch': 'yes' if self.batch else 'no', } + # Append our headers into our parameters + params.update({'+{}'.format(k): v for k, v in self.headers.items()}) + + # Append our template tokens into our parameters + params.update({':{}'.format(k): v for k, v in self.tokens.items()}) + # Extend our parameters params.update(self.url_parameters(privacy=privacy, *args, **kwargs)) @@ -327,13 +609,32 @@ class NotifyMailgun(NotifyBase): # from_name specified; pass it back on the url params['name'] = self.from_name + if self.cc: + # Handle our Carbon Copy Addresses + params['cc'] = ','.join( + ['{}{}'.format( + '' if not e not in self.names + else '{}:'.format(self.names[e]), e) for e in self.cc]) + + if self.bcc: + # Handle our Blind Carbon Copy Addresses + params['bcc'] = ','.join(self.bcc) + + # a simple boolean check as to whether we display our target emails + # or not + has_targets = \ + not (len(self.targets) == 1 + and self.targets[0][1] == self.from_addr) + return '{schema}://{user}@{host}/{apikey}/{targets}/?{params}'.format( schema=self.secure_protocol, host=self.host, user=NotifyMailgun.quote(self.user, safe=''), apikey=self.pprint(self.apikey, privacy, safe=''), - targets='/'.join( - [NotifyMailgun.quote(x, safe='') for x in self.targets]), + targets='' if not has_targets else '/'.join( + [NotifyMailgun.quote('{}{}'.format( + '' if not e[0] else '{}:'.format(e[0]), e[1]), + safe='') for e in self.targets]), params=NotifyMailgun.urlencode(params)) @staticmethod @@ -370,10 +671,30 @@ class NotifyMailgun(NotifyBase): results['region_name'] = \ NotifyMailgun.unquote(results['qsd']['region']) - # Support the 'to' variable so that we can support targets this way too - # The 'to' makes it easier to use yaml configuration + # Handle 'to' email address if 'to' in results['qsd'] and len(results['qsd']['to']): - results['targets'] += \ - NotifyMailgun.parse_list(results['qsd']['to']) + results['targets'].append(results['qsd']['to']) + + # Handle Carbon Copy Addresses + if 'cc' in results['qsd'] and len(results['qsd']['cc']): + results['cc'] = results['qsd']['cc'] + + # Handle Blind Carbon Copy Addresses + if 'bcc' in results['qsd'] and len(results['qsd']['bcc']): + results['bcc'] = results['qsd']['bcc'] + + # Add our Meta Headers that the user can provide with their outbound + # emails + results['headers'] = {NotifyBase.unquote(x): NotifyBase.unquote(y) + for x, y in results['qsd+'].items()} + + # Add our template tokens (if defined) + results['tokens'] = {NotifyBase.unquote(x): NotifyBase.unquote(y) + for x, y in results['qsd:'].items()} + + # Get Batch Mode Flag + results['batch'] = \ + parse_bool(results['qsd'].get( + 'batch', NotifyMailgun.template_args['batch']['default'])) return results diff --git a/libs/apprise/plugins/NotifyMatrix.py b/libs/apprise/plugins/NotifyMatrix.py index dd72352e2..b2103d995 100644 --- a/libs/apprise/plugins/NotifyMatrix.py +++ b/libs/apprise/plugins/NotifyMatrix.py @@ -30,6 +30,7 @@ import re import six import requests +from markdown import markdown from json import dumps from json import loads from time import time @@ -41,6 +42,7 @@ from ..common import NotifyImageSize from ..common import NotifyFormat from ..utils import parse_bool from ..utils import parse_list +from ..utils import apply_template from ..utils import validate_regex from ..AppriseLocale import gettext_lazy as _ @@ -65,6 +67,21 @@ IS_ROOM_ID = re.compile( r'(?P<home_server>[a-z0-9.-]+))?\s*$', re.I) +class MatrixMessageType(object): + """ + The Matrix Message types + """ + TEXT = "text" + NOTICE = "notice" + + +# matrix message types are placed into this list for validation purposes +MATRIX_MESSAGE_TYPES = ( + MatrixMessageType.TEXT, + MatrixMessageType.NOTICE, +) + + class MatrixWebhookMode(object): # Webhook Mode is disabled DISABLED = "off" @@ -79,7 +96,7 @@ class MatrixWebhookMode(object): T2BOT = "t2bot" -# webhook modes are placed ito this list for validation purposes +# webhook modes are placed into this list for validation purposes MATRIX_WEBHOOK_MODES = ( MatrixWebhookMode.DISABLED, MatrixWebhookMode.MATRIX, @@ -131,13 +148,13 @@ class NotifyMatrix(NotifyBase): '{schema}://{token}', '{schema}://{user}@{token}', - # All other non-t2bot setups require targets + # Disabled webhook '{schema}://{user}:{password}@{host}/{targets}', '{schema}://{user}:{password}@{host}:{port}/{targets}', - '{schema}://{token}:{password}@{host}/{targets}', - '{schema}://{token}:{password}@{host}:{port}/{targets}', - '{schema}://{user}:{token}:{password}@{host}/{targets}', - '{schema}://{user}:{token}:{password}@{host}:{port}/{targets}', + + # Webhook mode + '{schema}://{user}:{token}@{host}/{targets}', + '{schema}://{user}:{token}@{host}:{port}/{targets}', ) # Define our template tokens @@ -204,12 +221,22 @@ class NotifyMatrix(NotifyBase): 'values': MATRIX_WEBHOOK_MODES, 'default': MatrixWebhookMode.DISABLED, }, + 'msgtype': { + 'name': _('Message Type'), + 'type': 'choice:string', + 'values': MATRIX_MESSAGE_TYPES, + 'default': MatrixMessageType.TEXT, + }, 'to': { 'alias_of': 'targets', }, + 'token': { + 'alias_of': 'token', + }, }) - def __init__(self, targets=None, mode=None, include_image=False, **kwargs): + def __init__(self, targets=None, mode=None, msgtype=None, + include_image=False, **kwargs): """ Initialize Matrix Object """ @@ -235,20 +262,28 @@ class NotifyMatrix(NotifyBase): self._room_cache = {} # Setup our mode - self.mode = MatrixWebhookMode.DISABLED \ + self.mode = self.template_args['mode']['default'] \ if not isinstance(mode, six.string_types) else mode.lower() if self.mode and self.mode not in MATRIX_WEBHOOK_MODES: msg = 'The mode specified ({}) is invalid.'.format(mode) self.logger.warning(msg) raise TypeError(msg) + # Setup our message type + self.msgtype = self.template_args['msgtype']['default'] \ + if not isinstance(msgtype, six.string_types) else msgtype.lower() + if self.msgtype and self.msgtype not in MATRIX_MESSAGE_TYPES: + msg = 'The msgtype specified ({}) is invalid.'.format(msgtype) + self.logger.warning(msg) + raise TypeError(msg) + if self.mode == MatrixWebhookMode.T2BOT: # t2bot configuration requires that a webhook id is specified self.access_token = validate_regex( - self.host, r'^[a-z0-9]{64}$', 'i') + self.password, r'^[a-z0-9]{64}$', 'i') if not self.access_token: msg = 'An invalid T2Bot/Matrix Webhook ID ' \ - '({}) was specified.'.format(self.host) + '({}) was specified.'.format(self.password) self.logger.warning(msg) raise TypeError(msg) @@ -283,7 +318,7 @@ class NotifyMatrix(NotifyBase): default_port = 443 if self.secure else 80 # Prepare our URL - url = '{schema}://{hostname}:{port}/{webhook_path}/{token}'.format( + url = '{schema}://{hostname}:{port}{webhook_path}/{token}'.format( schema='https' if self.secure else 'http', hostname=self.host, port='' if self.port is None @@ -412,20 +447,31 @@ class NotifyMatrix(NotifyBase): payload = { 'displayName': self.user if self.user else self.app_id, - 'format': 'html', + 'format': 'plain' if self.notify_format == NotifyFormat.TEXT + else 'html', + 'text': '', } if self.notify_format == NotifyFormat.HTML: - payload['text'] = '{}{}'.format('' if not title else title, body) - - else: # TEXT or MARKDOWN + # Add additional information to our content; use {{app_title}} + # to apply the title to the html body + tokens = { + 'app_title': NotifyMatrix.escape_html( + title, whitespace=False), + } + payload['text'] = apply_template(body, **tokens) - # Ensure our content is escaped - title = NotifyMatrix.escape_html(title) - body = NotifyMatrix.escape_html(body) + elif self.notify_format == NotifyFormat.MARKDOWN: + # Add additional information to our content; use {{app_title}} + # to apply the title to the html body + tokens = { + 'app_title': title, + } + payload['text'] = markdown(apply_template(body, **tokens)) - payload['text'] = '{}{}'.format( - '' if not title else '<h4>{}</h4>'.format(title), body) + else: # NotifyFormat.TEXT + payload['text'] = \ + body if not title else '{}\r\n{}'.format(title, body) return payload @@ -494,11 +540,6 @@ class NotifyMatrix(NotifyBase): has_error = True continue - # We have our data cached at this point we can freely use it - msg = '{title}{body}'.format( - title='' if not title else '{}\r\n'.format(title), - body=body) - # Acquire our image url if we're configured to do so image_url = None if not self.include_image else \ self.image_url(notify_type) @@ -523,10 +564,36 @@ class NotifyMatrix(NotifyBase): # Define our payload payload = { - 'msgtype': 'm.text', - 'body': msg, + 'msgtype': 'm.{}'.format(self.msgtype), + 'body': '{title}{body}'.format( + title='' if not title else '{}\r\n'.format(title), + body=body), } + # Update our payload advance formatting for the services that + # support them. + if self.notify_format == NotifyFormat.HTML: + # Add additional information to our content; use {{app_title}} + # to apply the title to the html body + tokens = { + 'app_title': NotifyMatrix.escape_html( + title, whitespace=False), + } + + payload.update({ + 'format': 'org.matrix.custom.html', + 'formatted_body': apply_template(body, **tokens), + }) + + elif self.notify_format == NotifyFormat.MARKDOWN: + tokens = { + 'app_title': title, + } + payload.update({ + 'format': 'org.matrix.custom.html', + 'formatted_body': markdown(apply_template(body, **tokens)) + }) + # Build our path path = '/rooms/{}/send/m.room.message'.format( NotifyMatrix.quote(room_id)) @@ -694,7 +761,7 @@ class NotifyMatrix(NotifyBase): # Prepare our Join Payload payload = {} - # Not in cache, next step is to check if it's a room id... + # Check if it's a room id... result = IS_ROOM_ID.match(room) if result: # We detected ourselves the home_server @@ -707,11 +774,23 @@ class NotifyMatrix(NotifyBase): home_server, ) + # Check our cache for speed: + if room_id in self._room_cache: + # We're done as we've already joined the channel + return self._room_cache[room_id]['id'] + # Build our URL path = '/join/{}'.format(NotifyMatrix.quote(room_id)) # Make our query postokay, _ = self._fetch(path, payload=payload) + if postokay: + # Cache our entry for fast access later + self._room_cache[room_id] = { + 'id': room_id, + 'home_server': home_server, + } + return room_id if postokay else None # Try to see if it's an alias then... @@ -1003,9 +1082,54 @@ class NotifyMatrix(NotifyBase): """ Ensure we relinquish our token """ - if self.mode != MatrixWebhookMode.T2BOT: + if self.mode == MatrixWebhookMode.T2BOT: + # nothing to do + return + + try: self._logout() + except LookupError: # pragma: no cover + # Python v3.5 call to requests can sometimes throw the exception + # "/usr/lib64/python3.7/socket.py", line 748, in getaddrinfo + # LookupError: unknown encoding: idna + # + # This occurs every time when running unit-tests against Apprise: + # LANG=C.UTF-8 PYTHONPATH=$(pwd) py.test-3.7 + # + # There has been an open issue on this since Jan 2017. + # - https://bugs.python.org/issue29288 + # + # A ~similar~ issue can be identified here in the requests + # ticket system as unresolved and has provided work-arounds + # - https://github.com/kennethreitz/requests/issues/3578 + pass + + except ImportError: # pragma: no cover + # The actual exception is `ModuleNotFoundError` however ImportError + # grants us backwards compatiblity with versions of Python older + # than v3.6 + + # Python code that makes early calls to sys.exit() can cause + # the __del__() code to run. However in some newer versions of + # Python, this causes the `sys` library to no longer be + # available. The stack overflow also goes on to suggest that + # it's not wise to use the __del__() as a deconstructor + # which is the case here. + + # https://stackoverflow.com/questions/67218341/\ + # modulenotfounderror-import-of-time-halted-none-in-sys-\ + # modules-occured-when-obj?noredirect=1&lq=1 + # + # + # Also see: https://stackoverflow.com/questions\ + # /1481488/what-is-the-del-method-and-how-do-i-call-it + + # At this time it seems clean to try to log out (if we can) + # but not throw any unessisary exceptions (like this one) to + # the end user if we don't have to. + pass + def url(self, privacy=False, *args, **kwargs): """ Returns the URL built dynamically based on specified arguments. @@ -1015,31 +1139,36 @@ class NotifyMatrix(NotifyBase): params = { 'image': 'yes' if self.include_image else 'no', 'mode': self.mode, + 'msgtype': self.msgtype, } # 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=NotifyMatrix.quote(self.user, safe=''), - password=self.pprint( - self.password, privacy, mode=PrivacyMode.Secret, safe=''), - ) + if self.mode != MatrixWebhookMode.T2BOT: + # Determine Authentication + if self.user and self.password: + auth = '{user}:{password}@'.format( + user=NotifyMatrix.quote(self.user, safe=''), + password=self.pprint( + self.password, privacy, mode=PrivacyMode.Secret, + safe=''), + ) - elif self.user: - auth = '{user}@'.format( - user=NotifyMatrix.quote(self.user, safe=''), - ) + elif self.user: + auth = '{user}@'.format( + user=NotifyMatrix.quote(self.user, safe=''), + ) default_port = 443 if self.secure else 80 return '{schema}://{auth}{hostname}{port}/{rooms}?{params}'.format( schema=self.secure_protocol if self.secure else self.protocol, auth=auth, - hostname=NotifyMatrix.quote(self.host, safe=''), + hostname=NotifyMatrix.quote(self.host, safe='') + if self.mode != MatrixWebhookMode.T2BOT + else self.pprint(self.access_token, privacy, safe=''), port='' if self.port is None or self.port == default_port else ':{}'.format(self.port), rooms=NotifyMatrix.quote('/'.join(self.rooms)), @@ -1086,6 +1215,20 @@ class NotifyMatrix(NotifyBase): # Default mode to t2bot results['mode'] = MatrixWebhookMode.T2BOT + if results['mode'] and \ + results['mode'].lower() == MatrixWebhookMode.T2BOT: + # unquote our hostname and pass it in as the password/token + results['password'] = NotifyMatrix.unquote(results['host']) + + # Support the message type keyword + if 'msgtype' in results['qsd'] and len(results['qsd']['msgtype']): + results['msgtype'] = \ + NotifyMatrix.unquote(results['qsd']['msgtype']) + + # Support the use of the token= keyword + if 'token' in results['qsd'] and len(results['qsd']['token']): + results['password'] = NotifyMatrix.unquote(results['qsd']['token']) + return results @staticmethod diff --git a/libs/apprise/plugins/NotifyMatterMost.py b/libs/apprise/plugins/NotifyMatterMost.py index edd8202d6..842ceef96 100644 --- a/libs/apprise/plugins/NotifyMatterMost.py +++ b/libs/apprise/plugins/NotifyMatterMost.py @@ -23,6 +23,16 @@ # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN # THE SOFTWARE. +# Create an incoming webhook; the website will provide you with something like: +# http://localhost:8065/hooks/yobjmukpaw3r3urc5h6i369yima +# ^^^^^^^^^^^^^^^^^^^^^^^^^^^ +# |-- this is the webhook --| +# +# You can effectively turn the url above to read this: +# mmost://localhost:8065/yobjmukpaw3r3urc5h6i369yima +# - swap http with mmost +# - drop /hooks/ reference + import six import requests from json import dumps @@ -40,13 +50,13 @@ from ..AppriseLocale import gettext_lazy as _ # - https://docs.mattermost.com/administration/config-settings.html -class NotifyMatterMost(NotifyBase): +class NotifyMattermost(NotifyBase): """ - A wrapper for MatterMost Notifications + A wrapper for Mattermost Notifications """ # The default descriptive name associated with the Notification - service_name = 'MatterMost' + service_name = 'Mattermost' # The services URL service_url = 'https://mattermost.com/' @@ -74,14 +84,14 @@ class NotifyMatterMost(NotifyBase): # Define object templates templates = ( - '{schema}://{host}/{authtoken}', - '{schema}://{host}/{authtoken}:{port}', - '{schema}://{botname}@{host}/{authtoken}', - '{schema}://{botname}@{host}:{port}/{authtoken}', - '{schema}://{host}/{fullpath}/{authtoken}', - '{schema}://{host}/{fullpath}{authtoken}:{port}', - '{schema}://{botname}@{host}/{fullpath}/{authtoken}', - '{schema}://{botname}@{host}:{port}/{fullpath}/{authtoken}', + '{schema}://{host}/{token}', + '{schema}://{host}/{token}:{port}', + '{schema}://{botname}@{host}/{token}', + '{schema}://{botname}@{host}:{port}/{token}', + '{schema}://{host}/{fullpath}/{token}', + '{schema}://{host}/{fullpath}{token}:{port}', + '{schema}://{botname}@{host}/{fullpath}/{token}', + '{schema}://{botname}@{host}:{port}/{fullpath}/{token}', ) # Define our template tokens @@ -91,10 +101,9 @@ class NotifyMatterMost(NotifyBase): 'type': 'string', 'required': True, }, - 'authtoken': { - 'name': _('Access Key'), + 'token': { + 'name': _('Webhook Token'), 'type': 'string', - 'regex': (r'^[a-z0-9]{24,32}$', 'i'), 'private': True, 'required': True, }, @@ -132,12 +141,12 @@ class NotifyMatterMost(NotifyBase): }, }) - def __init__(self, authtoken, fullpath=None, channels=None, + def __init__(self, token, fullpath=None, channels=None, include_image=False, **kwargs): """ - Initialize MatterMost Object + Initialize Mattermost Object """ - super(NotifyMatterMost, self).__init__(**kwargs) + super(NotifyMattermost, self).__init__(**kwargs) if self.secure: self.schema = 'https' @@ -150,16 +159,15 @@ class NotifyMatterMost(NotifyBase): fullpath, six.string_types) else fullpath.strip() # Authorization Token (associated with project) - self.authtoken = validate_regex( - authtoken, *self.template_tokens['authtoken']['regex']) - if not self.authtoken: - msg = 'An invalid MatterMost Authorization Token ' \ - '({}) was specified.'.format(authtoken) + self.token = validate_regex(token) + if not self.token: + msg = 'An invalid Mattermost Authorization Token ' \ + '({}) was specified.'.format(token) self.logger.warning(msg) raise TypeError(msg) - # Optional Channels - self.channels = parse_list(channels) + # Optional Channels (strip off any channel prefix entries if present) + self.channels = [x.lstrip('#') for x in parse_list(channels)] if not self.port: self.port = self.default_port @@ -171,7 +179,7 @@ class NotifyMatterMost(NotifyBase): def send(self, body, title='', notify_type=NotifyType.INFO, **kwargs): """ - Perform MatterMost Notification + Perform Mattermost Notification """ # Create a copy of our channels, otherwise place a dummy entry @@ -211,12 +219,12 @@ class NotifyMatterMost(NotifyBase): url = '{}://{}:{}{}/hooks/{}'.format( self.schema, self.host, self.port, self.fullpath, - self.authtoken) + self.token) - self.logger.debug('MatterMost POST URL: %s (cert_verify=%r)' % ( + self.logger.debug('Mattermost POST URL: %s (cert_verify=%r)' % ( url, self.verify_certificate, )) - self.logger.debug('MatterMost Payload: %s' % str(payload)) + self.logger.debug('Mattermost Payload: %s' % str(payload)) # Always call throttle before any remote server i/o is made self.throttle() @@ -233,11 +241,11 @@ class NotifyMatterMost(NotifyBase): if r.status_code != requests.codes.ok: # We had a problem status_str = \ - NotifyMatterMost.http_response_code_lookup( + NotifyMattermost.http_response_code_lookup( r.status_code) self.logger.warning( - 'Failed to send MatterMost notification{}: ' + 'Failed to send Mattermost notification{}: ' '{}{}error={}.'.format( '' if not channel else ' to channel {}'.format(channel), @@ -254,13 +262,13 @@ class NotifyMatterMost(NotifyBase): else: self.logger.info( - 'Sent MatterMost notification{}.'.format( + 'Sent Mattermost notification{}.'.format( '' if not channel else ' to channel {}'.format(channel))) except requests.RequestException as e: self.logger.warning( - 'A Connection error occurred sending MatterMost ' + 'A Connection error occurred sending Mattermost ' 'notification{}.'.format( '' if not channel else ' to channel {}'.format(channel))) @@ -290,7 +298,8 @@ class NotifyMatterMost(NotifyBase): # historically the value only accepted one channel and is # therefore identified as 'channel'. Channels have always been # optional, so that is why this setting is nested in an if block - params['channel'] = ','.join(self.channels) + params['channel'] = ','.join( + [NotifyMattermost.quote(x, safe='') for x in self.channels]) default_port = 443 if self.secure else self.default_port default_schema = self.secure_protocol if self.secure else self.protocol @@ -299,11 +308,11 @@ class NotifyMatterMost(NotifyBase): botname = '' if self.user: botname = '{botname}@'.format( - botname=NotifyMatterMost.quote(self.user, safe=''), + botname=NotifyMattermost.quote(self.user, safe=''), ) return \ - '{schema}://{botname}{hostname}{port}{fullpath}{authtoken}' \ + '{schema}://{botname}{hostname}{port}{fullpath}{token}' \ '/?{params}'.format( schema=default_schema, botname=botname, @@ -313,9 +322,9 @@ class NotifyMatterMost(NotifyBase): port='' if not self.port or self.port == default_port else ':{}'.format(self.port), fullpath='/' if not self.fullpath else '{}/'.format( - NotifyMatterMost.quote(self.fullpath, safe='/')), - authtoken=self.pprint(self.authtoken, privacy, safe=''), - params=NotifyMatterMost.urlencode(params), + NotifyMattermost.quote(self.fullpath, safe='/')), + token=self.pprint(self.token, privacy, safe=''), + params=NotifyMattermost.urlencode(params), ) @staticmethod @@ -330,11 +339,11 @@ class NotifyMatterMost(NotifyBase): # We're done early as we couldn't load the results return results - # Acquire our tokens; the last one will always be our authtoken + # Acquire our tokens; the last one will always be our token # all entries before it will be our path - tokens = NotifyMatterMost.split_path(results['fullpath']) + tokens = NotifyMattermost.split_path(results['fullpath']) - results['authtoken'] = None if not tokens else tokens.pop() + results['token'] = None if not tokens else tokens.pop() # Store our path results['fullpath'] = '' if not tokens \ @@ -347,12 +356,12 @@ class NotifyMatterMost(NotifyBase): if 'to' in results['qsd'] and len(results['qsd']['to']): # Allow the user to specify the channel to post to results['channels'].append( - NotifyMatterMost.parse_list(results['qsd']['to'])) + NotifyMattermost.parse_list(results['qsd']['to'])) if 'channel' in results['qsd'] and len(results['qsd']['channel']): # Allow the user to specify the channel to post to results['channels'].append( - NotifyMatterMost.parse_list(results['qsd']['channel'])) + NotifyMattermost.parse_list(results['qsd']['channel'])) # Image manipulation results['include_image'] = \ diff --git a/libs/apprise/plugins/NotifyMessageBird.py b/libs/apprise/plugins/NotifyMessageBird.py index 1032f49b8..4b1da524e 100644 --- a/libs/apprise/plugins/NotifyMessageBird.py +++ b/libs/apprise/plugins/NotifyMessageBird.py @@ -29,18 +29,15 @@ # - https://dashboard.messagebird.com/en/user/index # -import re import requests from .NotifyBase import NotifyBase from ..common import NotifyType -from ..utils import parse_list +from ..utils import is_phone_no +from ..utils import parse_phone_no from ..utils import validate_regex from ..AppriseLocale import gettext_lazy as _ -# Some Phone Number Detection -IS_PHONE_NO = re.compile(r'^\+?(?P<phone>[0-9\s)(+-]+)\s*$') - class NotifyMessageBird(NotifyBase): """ @@ -129,28 +126,20 @@ class NotifyMessageBird(NotifyBase): self.logger.warning(msg) raise TypeError(msg) - result = IS_PHONE_NO.match(source) + result = is_phone_no(source) if not result: msg = 'The MessageBird source specified ({}) is invalid.'\ .format(source) self.logger.warning(msg) raise TypeError(msg) - # 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: - msg = 'The MessageBird source # specified ({}) is invalid.'\ - .format(source) - self.logger.warning(msg) - raise TypeError(msg) - # Store our source - self.source = result + self.source = result['full'] # Parse our targets self.targets = list() - targets = parse_list(targets) + targets = parse_phone_no(targets) if not targets: # No sources specified, use our own phone no self.targets.append(self.source) @@ -159,31 +148,16 @@ class NotifyMessageBird(NotifyBase): # otherwise, store all of our target numbers for target in 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) + result = is_phone_no(target) + if not result: + self.logger.warning( + 'Dropped invalid phone # ' + '({}) specified.'.format(target), + ) continue - self.logger.warning( - 'Dropped invalid phone # ' - '({}) specified.'.format(target), - ) - - if not self.targets: - # We have a bot token and no target(s) to message - msg = 'No MessageBird targets to notify.' - self.logger.warning(msg) - raise TypeError(msg) + # store valid phone number + self.targets.append(result['full']) return @@ -192,6 +166,11 @@ class NotifyMessageBird(NotifyBase): Perform MessageBird Notification """ + if len(self.targets) == 0: + # There were no services to notify + self.logger.warning('There were no MessageBird targets to notify.') + return False + # error tracking (used for function return) has_error = False @@ -345,6 +324,7 @@ class NotifyMessageBird(NotifyBase): try: # The first path entry is the source/originator results['source'] = results['targets'].pop(0) + except IndexError: # No path specified... this URL is potentially un-parseable; we can # hope for a from= entry @@ -357,7 +337,7 @@ class NotifyMessageBird(NotifyBase): # The 'to' makes it easier to use yaml configuration if 'to' in results['qsd'] and len(results['qsd']['to']): results['targets'] += \ - NotifyMessageBird.parse_list(results['qsd']['to']) + NotifyMessageBird.parse_phone_no(results['qsd']['to']) if 'from' in results['qsd'] and len(results['qsd']['from']): results['source'] = \ diff --git a/libs/apprise/plugins/NotifyNexmo.py b/libs/apprise/plugins/NotifyNexmo.py index 05c9f7fcd..1c423aad6 100644 --- a/libs/apprise/plugins/NotifyNexmo.py +++ b/libs/apprise/plugins/NotifyNexmo.py @@ -28,20 +28,16 @@ # Get your (api) key and secret here: # - https://dashboard.nexmo.com/getting-started-guide # - -import re import requests from .NotifyBase import NotifyBase from ..URLBase import PrivacyMode from ..common import NotifyType -from ..utils import parse_list +from ..utils import is_phone_no +from ..utils import parse_phone_no from ..utils import validate_regex from ..AppriseLocale import gettext_lazy as _ -# Some Phone Number Detection -IS_PHONE_NO = re.compile(r'^\+?(?P<phone>[0-9\s)(+-]+)\s*$') - class NotifyNexmo(NotifyBase): """ @@ -185,44 +181,31 @@ class NotifyNexmo(NotifyBase): # The Source Phone # self.source = source - if not IS_PHONE_NO.match(self.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 = re.sub(r'[^\d]+', '', self.source) - if len(self.source) < 11 or len(self.source) > 14: - msg = 'The Account (From) Phone # specified ' \ - '({}) contains an invalid digit count.'.format(source) - self.logger.warning(msg) - raise TypeError(msg) + # Store our parsed value + self.source = result['full'] # Parse our targets self.targets = list() - for target in parse_list(targets): + for target in parse_phone_no(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) + result = is_phone_no(target) + if not result: + self.logger.warning( + 'Dropped invalid phone # ' + '({}) specified.'.format(target), + ) continue - self.logger.warning( - 'Dropped invalid phone # ' - '({}) specified.'.format(target), - ) + # store valid phone number + self.targets.append(result['full']) return @@ -393,10 +376,10 @@ class NotifyNexmo(NotifyBase): results['ttl'] = \ NotifyNexmo.unquote(results['qsd']['ttl']) - # Support the 'to' variable so that we can support targets this way too + # 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'] += \ - NotifyNexmo.parse_list(results['qsd']['to']) + NotifyNexmo.parse_phone_no(results['qsd']['to']) return results diff --git a/libs/apprise/plugins/NotifyNextcloud.py b/libs/apprise/plugins/NotifyNextcloud.py index 240ed0aa1..30000f0d9 100644 --- a/libs/apprise/plugins/NotifyNextcloud.py +++ b/libs/apprise/plugins/NotifyNextcloud.py @@ -51,11 +51,7 @@ class NotifyNextcloud(NotifyBase): # A URL that takes you to the setup/help of the specific protocol setup_url = 'https://github.com/caronc/apprise/wiki/Notify_nextcloud' - # Nextcloud URL - notify_url = '{schema}://{host}/ocs/v2.php/apps/admin_notifications/' \ - 'api/v1/notifications/{target}' - - # Nextcloud does not support a title + # Nextcloud title length title_maxlen = 255 # Defines the maximum allowable characters per message. @@ -101,6 +97,22 @@ class NotifyNextcloud(NotifyBase): }, }) + # Define our template arguments + template_args = dict(NotifyBase.template_args, **{ + # Nextcloud uses different API end points depending on the version + # being used however the (API) payload remains the same. Allow users + # to specify the version they are using: + 'version': { + 'name': _('Version'), + 'type': 'int', + 'min': 1, + 'default': 21, + }, + 'to': { + 'alias_of': 'targets', + }, + }) + # Define any kwargs we're using template_kwargs = { 'headers': { @@ -109,7 +121,7 @@ class NotifyNextcloud(NotifyBase): }, } - def __init__(self, targets=None, headers=None, **kwargs): + def __init__(self, targets=None, version=None, headers=None, **kwargs): """ Initialize Nextcloud Object """ @@ -121,6 +133,20 @@ class NotifyNextcloud(NotifyBase): self.logger.warning(msg) raise TypeError(msg) + self.version = self.template_args['version']['default'] + if version is not None: + try: + self.version = int(version) + if self.version < self.template_args['version']['min']: + # Let upper exception handle this + raise ValueError() + + except (ValueError, TypeError): + msg = 'At invalid Nextcloud version ({}) was specified.'\ + .format(version) + self.logger.warning(msg) + raise TypeError(msg) + self.headers = {} if headers: # Store our extra headers @@ -163,17 +189,28 @@ class NotifyNextcloud(NotifyBase): if self.user: auth = (self.user, self.password) - notify_url = self.notify_url.format( + # Nextcloud URL based on version used + notify_url = '{schema}://{host}/ocs/v2.php/'\ + 'apps/admin_notifications/' \ + 'api/v1/notifications/{target}' \ + if self.version < 21 else \ + '{schema}://{host}/ocs/v2.php/'\ + 'apps/notifications/'\ + 'api/v2/admin_notifications/{target}' + + notify_url = notify_url.format( schema='https' if self.secure else 'http', host=self.host if not isinstance(self.port, int) else '{}:{}'.format(self.host, self.port), target=target, ) - self.logger.debug('Nextcloud POST URL: %s (cert_verify=%r)' % ( - notify_url, self.verify_certificate, - )) - self.logger.debug('Nextcloud Payload: %s' % str(payload)) + self.logger.debug( + 'Nextcloud v%d POST URL: %s (cert_verify=%r)', + self.version, notify_url, self.verify_certificate) + self.logger.debug( + 'Nextcloud v%d Payload: %s', + self.version, str(payload)) # Always call throttle before any remote server i/o is made self.throttle() @@ -194,8 +231,9 @@ class NotifyNextcloud(NotifyBase): r.status_code) self.logger.warning( - 'Failed to send Nextcloud notification:' + 'Failed to send Nextcloud v{} notification:' '{}{}error={}.'.format( + self.version, status_str, ', ' if status_str else '', r.status_code)) @@ -207,13 +245,13 @@ class NotifyNextcloud(NotifyBase): continue else: - self.logger.info('Sent Nextcloud notification.') + self.logger.info( + 'Sent Nextcloud %d notification.', self.version) except requests.RequestException as e: self.logger.warning( - 'A Connection error occurred sending Nextcloud ' - 'notification.', - ) + 'A Connection error occurred sending Nextcloud v%d' + 'notification.', self.version) self.logger.debug('Socket Exception: %s' % str(e)) # track our failure @@ -230,8 +268,11 @@ class NotifyNextcloud(NotifyBase): # Create URL parameters from our headers params = {'+{}'.format(k): v for k, v in self.headers.items()} - # Our URL parameters - params = self.url_parameters(privacy=privacy, *args, **kwargs) + # Set our version + params['version'] = str(self.version) + + # Extend our parameters + params.update(self.url_parameters(privacy=privacy, *args, **kwargs)) # Determine Authentication auth = '' @@ -285,9 +326,18 @@ class NotifyNextcloud(NotifyBase): results['targets'] += \ NotifyNextcloud.parse_list(results['qsd']['to']) + # Allow users to over-ride the Nextcloud version being used + if 'version' in results['qsd'] and len(results['qsd']['version']): + results['version'] = \ + NotifyNextcloud.unquote(results['qsd']['version']) + # 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+']) + results['headers'] = results['qsd+'] + if results['qsd-']: + results['headers'].update(results['qsd-']) + NotifyBase.logger.deprecate( + "minus (-) based Nextcloud header tokens are being " + " removed; use the plus (+) symbol instead.") return results diff --git a/libs/apprise/plugins/NotifyNotica.py b/libs/apprise/plugins/NotifyNotica.py index 3dcc0172a..2a031e387 100644 --- a/libs/apprise/plugins/NotifyNotica.py +++ b/libs/apprise/plugins/NotifyNotica.py @@ -365,8 +365,12 @@ class NotifyNotica(NotifyBase): # 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+']) + results['headers'] = results['qsd+'] + if results['qsd-']: + results['headers'].update(results['qsd-']) + NotifyBase.logger.deprecate( + "minus (-) based Notica header tokens are being " + " removed; use the plus (+) symbol instead.") return results diff --git a/libs/apprise/plugins/NotifyOneSignal.py b/libs/apprise/plugins/NotifyOneSignal.py new file mode 100644 index 000000000..3d936f5be --- /dev/null +++ b/libs/apprise/plugins/NotifyOneSignal.py @@ -0,0 +1,495 @@ +# -*- coding: utf-8 -*- +# +# Copyright (C) 2020 Chris Caron <[email protected]> +# All rights reserved. +# +# This code is licensed under the MIT License. +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files(the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and / or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions : +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. + +# One Signal requires that you've signed up with the service and +# generated yourself an API Key and APP ID. + +# Sources: +# - https://documentation.onesignal.com/docs/accounts-and-keys +# - https://documentation.onesignal.com/reference/create-notification + +import requests +from json import dumps +from itertools import chain + +from .NotifyBase import NotifyBase +from ..common import NotifyType +from ..common import NotifyImageSize +from ..utils import validate_regex +from ..utils import parse_list +from ..utils import parse_bool +from ..utils import is_email +from ..AppriseLocale import gettext_lazy as _ + + +class OneSignalCategory(NotifyBase): + """ + We define the different category types that we can notify via OneSignal + """ + PLAYER = 'include_player_ids' + EMAIL = 'include_email_tokens' + USER = 'include_external_user_ids' + SEGMENT = 'included_segments' + + +ONESIGNAL_CATEGORIES = ( + OneSignalCategory.PLAYER, + OneSignalCategory.EMAIL, + OneSignalCategory.USER, + OneSignalCategory.SEGMENT, +) + + +class NotifyOneSignal(NotifyBase): + """ + A wrapper for OneSignal Notifications + """ + # The default descriptive name associated with the Notification + service_name = 'OneSignal' + + # The services URL + service_url = 'https://onesignal.com' + + # The default protocol + secure_protocol = 'onesignal' + + # A URL that takes you to the setup/help of the specific protocol + setup_url = 'https://github.com/caronc/apprise/wiki/Notify_onesignal' + + # Notification + notify_url = "https://onesignal.com/api/v1/notifications" + + # Allows the user to specify the NotifyImageSize object + image_size = NotifyImageSize.XY_72 + + # The maximum allowable batch sizes per message + maximum_batch_size = 2000 + + # Define object templates + templates = ( + '{schema}://{app}@{apikey}/{targets}', + '{schema}://{template}:{app}@{apikey}/{targets}', + ) + + # Define our template + template_tokens = dict(NotifyBase.template_tokens, **{ + # The App_ID is a UUID + # such as: 8250eaf6-1a58-489e-b136-7c74a864b434 + 'app': { + 'name': _('App ID'), + 'type': 'string', + 'private': True, + 'required': True, + }, + 'template': { + 'name': _('Template'), + 'type': 'string', + 'private': True, + }, + 'apikey': { + 'name': _('API Key'), + 'type': 'string', + 'private': True, + 'required': True, + }, + 'target_device': { + 'name': _('Target Player ID'), + 'type': 'string', + 'map_to': 'targets', + }, + 'target_email': { + 'name': _('Target Email'), + 'type': 'string', + 'map_to': 'targets', + }, + 'target_user': { + 'name': _('Target User'), + 'type': 'string', + 'prefix': '@', + 'map_to': 'targets', + }, + 'target_segment': { + 'name': _('Include Segment'), + 'type': 'string', + 'prefix': '#', + 'map_to': 'targets', + }, + 'targets': { + 'name': _('Targets'), + 'type': 'list:string', + }, + }) + + template_args = dict(NotifyBase.template_args, **{ + 'to': { + 'alias_of': 'targets', + }, + 'image': { + 'name': _('Include Image'), + 'type': 'bool', + 'default': True, + 'map_to': 'include_image', + }, + 'batch': { + 'name': _('Batch Mode'), + 'type': 'bool', + 'default': False, + }, + 'template': { + 'alias_of': 'template', + }, + 'subtitle': { + 'name': _('Subtitle'), + 'type': 'string', + }, + 'language': { + 'name': _('Language'), + 'type': 'string', + 'default': 'en', + }, + }) + + def __init__(self, app, apikey, targets=None, include_image=True, + template=None, subtitle=None, language=None, batch=False, + **kwargs): + """ + Initialize OneSignal + + """ + super(NotifyOneSignal, self).__init__(**kwargs) + + # The apikey associated with the account + self.apikey = validate_regex(apikey) + if not self.apikey: + msg = 'An invalid OneSignal API key ' \ + '({}) was specified.'.format(apikey) + self.logger.warning(msg) + raise TypeError(msg) + + # The App ID associated with the account + self.app = validate_regex(app) + if not self.app: + msg = 'An invalid OneSignal Application ID ' \ + '({}) was specified.'.format(app) + self.logger.warning(msg) + raise TypeError(msg) + + # Prepare Batch Mode Flag + self.batch_size = self.maximum_batch_size if batch else 1 + + # Place a thumbnail image inline with the message body + self.include_image = include_image + + # Our Assorted Types of Targets + self.targets = { + OneSignalCategory.PLAYER: [], + OneSignalCategory.EMAIL: [], + OneSignalCategory.USER: [], + OneSignalCategory.SEGMENT: [], + } + + # Assign our template (if defined) + self.template_id = template + + # Assign our subtitle (if defined) + self.subtitle = subtitle + + # Our Language + self.language = language.strip().lower()[0:2]\ + if language \ + else NotifyOneSignal.template_args['language']['default'] + + if not self.language or len(self.language) != 2: + msg = 'An invalid OneSignal Language ({}) was specified.'.format( + language) + self.logger.warning(msg) + raise TypeError(msg) + + # Sort our targets + for _target in parse_list(targets): + target = _target.strip() + if len(target) < 2: + self.logger.debug('Ignoring OneSignal Entry: %s' % target) + continue + + if target.startswith( + NotifyOneSignal.template_tokens + ['target_user']['prefix']): + + self.targets[OneSignalCategory.USER].append(target) + self.logger.debug( + 'Detected OneSignal UserID: %s' % + self.targets[OneSignalCategory.USER][-1]) + continue + + if target.startswith( + NotifyOneSignal.template_tokens + ['target_segment']['prefix']): + + self.targets[OneSignalCategory.SEGMENT].append(target) + self.logger.debug( + 'Detected OneSignal Include Segment: %s' % + self.targets[OneSignalCategory.SEGMENT][-1]) + continue + + result = is_email(target) + if result: + self.targets[OneSignalCategory.EMAIL]\ + .append(result['full_email']) + self.logger.debug( + 'Detected OneSignal Email: %s' % + self.targets[OneSignalCategory.EMAIL][-1]) + + else: + # Add element as Player ID + self.targets[OneSignalCategory.PLAYER].append(target) + self.logger.debug( + 'Detected OneSignal Player ID: %s' % + self.targets[OneSignalCategory.PLAYER][-1]) + + return + + def send(self, body, title='', notify_type=NotifyType.INFO, **kwargs): + """ + Perform OneSignal Notification + """ + + headers = { + 'User-Agent': self.app_id, + 'Content-Type': 'application/json; charset=utf-8', + "Authorization": "Basic {}".format(self.apikey), + } + + has_error = False + sent_count = 0 + + payload = { + 'app_id': self.app, + + 'headings': { + self.language: title if title else self.app_desc, + }, + 'contents': { + self.language: body, + }, + + # Sending true wakes your app from background to run custom native + # code (Apple interprets this as content-available=1). + # Note: Not applicable if the app is in the "force-quit" state + # (i.e app was swiped away). Omit the contents field to + # prevent displaying a visible notification. + 'content_available': True, + } + + if self.subtitle: + payload.update({ + 'subtitle': { + self.language: self.subtitle, + }, + }) + + if self.template_id: + payload['template_id'] = self.template_id + + # Acquire our large_icon image URL (if set) + image_url = None if not self.include_image \ + else self.image_url(notify_type) + if image_url: + payload['large_icon'] = image_url + + # Acquire our small_icon image URL (if set) + image_url = None if not self.include_image \ + else self.image_url(notify_type, image_size=NotifyImageSize.XY_32) + if image_url: + payload['small_icon'] = image_url + + for category in ONESIGNAL_CATEGORIES: + # Create a pointer to our list of targets for specified category + targets = self.targets[category] + for index in range(0, len(targets), self.batch_size): + payload[category] = targets[index:index + self.batch_size] + + # Track our sent count + sent_count += len(payload[category]) + + self.logger.debug('OneSignal POST URL: %s (cert_verify=%r)' % ( + self.notify_url, self.verify_certificate, + )) + self.logger.debug('OneSignal 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 not in ( + requests.codes.ok, requests.codes.no_content): + # We had a problem + status_str = \ + NotifyOneSignal.http_response_code_lookup( + r.status_code) + + self.logger.warning( + 'Failed to send OneSignal notification: ' + '{}{}error={}.'.format( + status_str, + ', ' if status_str else '', + r.status_code)) + + self.logger.debug( + 'Response Details:\r\n%s', r.content) + + has_error = True + + else: + self.logger.info('Sent OneSignal notification.') + + except requests.RequestException as e: + self.logger.warning( + 'A Connection error occurred sending OneSignal ' + 'notification.' + ) + self.logger.debug('Socket Exception: %s', str(e)) + + has_error = True + + if not sent_count: + # There is no one to notify; we need to capture this and not + # return a valid + self.logger.warning('There are no OneSignal targets to notify') + return False + + 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', + 'batch': 'yes' if self.batch_size > 1 else 'no', + } + + # Extend our parameters + params.update(self.url_parameters(privacy=privacy, *args, **kwargs)) + + return '{schema}://{tp_id}{app}@{apikey}/{targets}?{params}'.format( + schema=self.secure_protocol, + tp_id='{}:'.format( + self.pprint(self.template_id, privacy, safe='')) + if self.template_id else '', + app=self.pprint(self.app, privacy, safe=''), + apikey=self.pprint(self.apikey, privacy, safe=''), + targets='/'.join(chain( + [NotifyOneSignal.quote(x) + for x in self.targets[OneSignalCategory.PLAYER]], + [NotifyOneSignal.quote(x) + for x in self.targets[OneSignalCategory.EMAIL]], + [NotifyOneSignal.quote('{}{}'.format( + NotifyOneSignal.template_tokens + ['target_user']['prefix'], x), safe='') + for x in self.targets[OneSignalCategory.USER]], + [NotifyOneSignal.quote('{}{}'.format( + NotifyOneSignal.template_tokens + ['target_segment']['prefix'], x), safe='') + for x in self.targets[OneSignalCategory.SEGMENT]])), + params=NotifyOneSignal.urlencode(params), + ) + + @staticmethod + def parse_url(url): + """ + Parses the URL and returns enough arguments that can allow + us to re-instantiate this object. + + """ + results = NotifyBase.parse_url(url, verify_host=False) + if not results: + # We're done early as we couldn't load the results + return results + + if not results.get('password'): + # The APP ID identifier associated with the account + results['app'] = NotifyOneSignal.unquote(results['user']) + + else: + # The APP ID identifier associated with the account + results['app'] = NotifyOneSignal.unquote(results['password']) + # The Template ID + results['template'] = NotifyOneSignal.unquote(results['user']) + + # Get Image Boolean (if set) + results['include_image'] = \ + parse_bool( + results['qsd'].get( + 'image', + NotifyOneSignal.template_args['image']['default'])) + + # Get Batch Boolean (if set) + results['batch'] = \ + parse_bool( + results['qsd'].get( + 'batch', + NotifyOneSignal.template_args['batch']['default'])) + + # The API Key is stored in the hostname + results['apikey'] = NotifyOneSignal.unquote(results['host']) + + # Get our Targets + results['targets'] = NotifyOneSignal.split_path(results['fullpath']) + + # The 'to' makes it easier to use yaml configuration + if 'to' in results['qsd'] and len(results['qsd']['to']): + results['targets'] += \ + NotifyOneSignal.parse_list(results['qsd']['to']) + + if 'app' in results['qsd'] and len(results['qsd']['app']): + results['app'] = \ + NotifyOneSignal.unquote(results['qsd']['app']) + + if 'apikey' in results['qsd'] and len(results['qsd']['apikey']): + results['apikey'] = \ + NotifyOneSignal.unquote(results['qsd']['apikey']) + + if 'template' in results['qsd'] and len(results['qsd']['template']): + results['template'] = \ + NotifyOneSignal.unquote(results['qsd']['template']) + + if 'subtitle' in results['qsd'] and len(results['qsd']['subtitle']): + results['subtitle'] = \ + NotifyOneSignal.unquote(results['qsd']['subtitle']) + + if 'lang' in results['qsd'] and len(results['qsd']['lang']): + results['language'] = \ + NotifyOneSignal.unquote(results['qsd']['lang']) + + return results diff --git a/libs/apprise/plugins/NotifyOpsgenie.py b/libs/apprise/plugins/NotifyOpsgenie.py new file mode 100644 index 000000000..da63a1d8a --- /dev/null +++ b/libs/apprise/plugins/NotifyOpsgenie.py @@ -0,0 +1,601 @@ +# -*- coding: utf-8 -*- +# +# Copyright (C) 2020 Chris Caron <[email protected]> +# All rights reserved. +# +# This code is licensed under the MIT License. +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files(the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and / or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions : +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. + +# Signup @ https://www.opsgenie.com +# +# Generate your Integration API Key +# https://app.opsgenie.com/settings/integration/add/API/ + +# Knowing this, you can build your Opsgenie URL as follows: +# opsgenie://{apikey}/ +# opsgenie://{apikey}/@{user} +# opsgenie://{apikey}/*{schedule} +# opsgenie://{apikey}/^{escalation} +# opsgenie://{apikey}/#{team} +# +# You can mix and match what you want to notify freely +# opsgenie://{apikey}/@{user}/#{team}/*{schedule}/^{escalation} +# +# If no target prefix is specified, then it is assumed to be a user. +# +# API Documentation: https://docs.opsgenie.com/docs/alert-api +# API Integration Docs: https://docs.opsgenie.com/docs/api-integration + +import requests +from json import dumps + +from .NotifyBase import NotifyBase +from ..common import NotifyType +from ..utils import validate_regex +from ..utils import is_uuid +from ..utils import parse_list +from ..utils import parse_bool +from ..AppriseLocale import gettext_lazy as _ + + +class OpsgenieCategory(NotifyBase): + """ + We define the different category types that we can notify + """ + USER = 'user' + SCHEDULE = 'schedule' + ESCALATION = 'escalation' + TEAM = 'team' + + +OPSGENIE_CATEGORIES = ( + OpsgenieCategory.USER, + OpsgenieCategory.SCHEDULE, + OpsgenieCategory.ESCALATION, + OpsgenieCategory.TEAM, +) + + +# Regions +class OpsgenieRegion(object): + US = 'us' + EU = 'eu' + + +# Opsgenie APIs +OPSGENIE_API_LOOKUP = { + OpsgenieRegion.US: 'https://api.opsgenie.com/v2/alerts', + OpsgenieRegion.EU: 'https://api.eu.opsgenie.com/v2/alerts', +} + +# A List of our regions we can use for verification +OPSGENIE_REGIONS = ( + OpsgenieRegion.US, + OpsgenieRegion.EU, +) + + +# Priorities +class OpsgeniePriority(object): + LOW = 1 + MODERATE = 2 + NORMAL = 3 + HIGH = 4 + EMERGENCY = 5 + + +OPSGENIE_PRIORITIES = ( + OpsgeniePriority.LOW, + OpsgeniePriority.MODERATE, + OpsgeniePriority.NORMAL, + OpsgeniePriority.HIGH, + OpsgeniePriority.EMERGENCY, +) + + +class NotifyOpsgenie(NotifyBase): + """ + A wrapper for Opsgenie Notifications + """ + + # The default descriptive name associated with the Notification + service_name = 'Opsgenie' + + # The services URL + service_url = 'https://opsgenie.com/' + + # All notification requests are secure + secure_protocol = 'opsgenie' + + # A URL that takes you to the setup/help of the specific protocol + setup_url = 'https://github.com/caronc/apprise/wiki/Notify_opsgenie' + + # The maximum length of the body + body_maxlen = 15000 + + # If we don't have the specified min length, then we don't bother using + # the body directive + opsgenie_body_minlen = 130 + + # The default region to use if one isn't otherwise specified + opsgenie_default_region = OpsgenieRegion.US + + # The maximum allowable targets within a notification + maximum_batch_size = 50 + + # Define object templates + templates = ( + '{schema}://{apikey}', + '{schema}://{apikey}/{targets}', + ) + + # Define our template tokens + template_tokens = dict(NotifyBase.template_tokens, **{ + 'apikey': { + 'name': _('API Key'), + 'type': 'string', + 'private': True, + 'required': True, + }, + 'target_escalation': { + 'name': _('Target Escalation'), + 'prefix': '^', + 'type': 'string', + 'map_to': 'targets', + }, + 'target_schedule': { + 'name': _('Target Schedule'), + 'type': 'string', + 'prefix': '*', + 'map_to': 'targets', + }, + 'target_user': { + 'name': _('Target User'), + 'type': 'string', + 'prefix': '@', + 'map_to': 'targets', + }, + 'target_team': { + 'name': _('Target Team'), + 'type': 'string', + 'prefix': '#', + 'map_to': 'targets', + }, + 'targets': { + 'name': _('Targets '), + 'type': 'list:string', + }, + }) + + # Define our template arguments + template_args = dict(NotifyBase.template_args, **{ + 'region': { + 'name': _('Region Name'), + 'type': 'choice:string', + 'values': OPSGENIE_REGIONS, + 'default': OpsgenieRegion.US, + 'map_to': 'region_name', + }, + 'batch': { + 'name': _('Batch Mode'), + 'type': 'bool', + 'default': False, + }, + 'priority': { + 'name': _('Priority'), + 'type': 'choice:int', + 'values': OPSGENIE_PRIORITIES, + 'default': OpsgeniePriority.NORMAL, + }, + 'entity': { + 'name': _('Entity'), + 'type': 'string', + }, + 'alias': { + 'name': _('Alias'), + 'type': 'string', + }, + 'tags': { + 'name': _('Tags'), + 'type': 'string', + }, + 'to': { + 'alias_of': 'targets', + }, + }) + + # Map of key-value pairs to use as custom properties of the alert. + template_kwargs = { + 'details': { + 'name': _('Details'), + 'prefix': '+', + }, + } + + def __init__(self, apikey, targets, region_name=None, details=None, + priority=None, alias=None, entity=None, batch=False, + tags=None, **kwargs): + """ + Initialize Opsgenie Object + """ + super(NotifyOpsgenie, self).__init__(**kwargs) + + # API Key (associated with project) + self.apikey = validate_regex(apikey) + if not self.apikey: + msg = 'An invalid Opsgenie API Key ' \ + '({}) was specified.'.format(apikey) + self.logger.warning(msg) + raise TypeError(msg) + + # The Priority of the message + if priority not in OPSGENIE_PRIORITIES: + self.priority = OpsgeniePriority.NORMAL + + else: + self.priority = priority + + # Store our region + try: + self.region_name = self.opsgenie_default_region \ + if region_name is None else region_name.lower() + + if self.region_name not in OPSGENIE_REGIONS: + # allow the outer except to handle this common response + raise + except: + # Invalid region specified + msg = 'The Opsgenie region specified ({}) is invalid.' \ + .format(region_name) + self.logger.warning(msg) + raise TypeError(msg) + + self.details = {} + if details: + # Store our extra details + self.details.update(details) + + # Prepare Batch Mode Flag + self.batch_size = self.maximum_batch_size if batch else 1 + + # Assign our tags (if defined) + self.__tags = parse_list(tags) + + # Assign our entity (if defined) + self.entity = entity + + # Assign our alias (if defined) + self.alias = alias + + # Initialize our Targets + self.targets = [] + + # Sort our targets + for _target in parse_list(targets): + target = _target.strip() + if len(target) < 2: + self.logger.debug('Ignoring Opsgenie Entry: %s' % target) + continue + + if target.startswith(NotifyOpsgenie.template_tokens + ['target_team']['prefix']): + + self.targets.append( + {'type': OpsgenieCategory.TEAM, 'id': target[1:]} + if is_uuid(target[1:]) else + {'type': OpsgenieCategory.TEAM, 'name': target[1:]}) + + elif target.startswith(NotifyOpsgenie.template_tokens + ['target_schedule']['prefix']): + + self.targets.append( + {'type': OpsgenieCategory.SCHEDULE, 'id': target[1:]} + if is_uuid(target[1:]) else + {'type': OpsgenieCategory.SCHEDULE, 'name': target[1:]}) + + elif target.startswith(NotifyOpsgenie.template_tokens + ['target_escalation']['prefix']): + + self.targets.append( + {'type': OpsgenieCategory.ESCALATION, 'id': target[1:]} + if is_uuid(target[1:]) else + {'type': OpsgenieCategory.ESCALATION, 'name': target[1:]}) + + elif target.startswith(NotifyOpsgenie.template_tokens + ['target_user']['prefix']): + + self.targets.append( + {'type': OpsgenieCategory.USER, 'id': target[1:]} + if is_uuid(target[1:]) else + {'type': OpsgenieCategory.USER, 'username': target[1:]}) + + else: + # Ambiguious entry; treat it as a user but not before + # displaying a warning to the end user first: + self.logger.debug( + 'Treating ambigious Opsgenie target %s as a user', target) + self.targets.append( + {'type': OpsgenieCategory.USER, 'id': target} + if is_uuid(target) else + {'type': OpsgenieCategory.USER, 'username': target}) + + def send(self, body, title='', notify_type=NotifyType.INFO, **kwargs): + """ + Perform Opsgenie Notification + """ + + headers = { + 'User-Agent': self.app_id, + 'Content-Type': 'application/json', + 'Authorization': 'GenieKey {}'.format(self.apikey), + } + + # Prepare our URL as it's based on our hostname + notify_url = OPSGENIE_API_LOOKUP[self.region_name] + + # Initialize our has_error flag + has_error = False + + # We want to manually set the title onto the body if specified + title_body = body if not title else '{}: {}'.format(title, body) + + # Create a copy ouf our details object + details = self.details.copy() + if 'type' not in details: + details['type'] = notify_type + + # Prepare our payload + payload = { + 'source': self.app_desc, + 'message': title_body, + 'description': body, + 'details': details, + 'priority': 'P{}'.format(self.priority), + } + + # Use our body directive if we exceed the minimum message + # limitation + if len(payload['message']) > self.opsgenie_body_minlen: + payload['message'] = '{}...'.format( + body[:self.opsgenie_body_minlen - 3]) + + if self.__tags: + payload['tags'] = self.__tags + + if self.entity: + payload['entity'] = self.entity + + if self.alias: + payload['alias'] = self.alias + + length = len(self.targets) if self.targets else 1 + for index in range(0, length, self.batch_size): + if self.targets: + # If there were no targets identified, then we simply + # just iterate once without the responders set + payload['responders'] = \ + self.targets[index:index + self.batch_size] + + # Some Debug Logging + self.logger.debug( + 'Opsgenie POST URL: {} (cert_verify={})'.format( + notify_url, self.verify_certificate)) + self.logger.debug('Opsgenie 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, + verify=self.verify_certificate, + timeout=self.request_timeout, + ) + + if r.status_code not in ( + requests.codes.accepted, requests.codes.ok): + status_str = \ + NotifyBase.http_response_code_lookup( + r.status_code) + + self.logger.warning( + 'Failed to send Opsgenie notification:' + '{}{}error={}.'.format( + 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 + + # If we reach here; the message was sent + self.logger.info('Sent Opsgenie notification') + self.logger.debug( + 'Response Details:\r\n{}'.format(r.content)) + + except requests.RequestException as e: + self.logger.warning( + 'A Connection error occurred sending Opsgenie ' + 'notification.') + 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. + """ + + _map = { + OpsgeniePriority.LOW: 'low', + OpsgeniePriority.MODERATE: 'moderate', + OpsgeniePriority.NORMAL: 'normal', + OpsgeniePriority.HIGH: 'high', + OpsgeniePriority.EMERGENCY: 'emergency', + } + + # Define any URL parameters + params = { + 'region': self.region_name, + 'priority': + _map[OpsgeniePriority.NORMAL] if self.priority not in _map + else _map[self.priority], + 'batch': 'yes' if self.batch_size > 1 else 'no', + } + + # Assign our entity value (if defined) + if self.entity: + params['entity'] = self.entity + + # Assign our alias value (if defined) + if self.alias: + params['alias'] = self.alias + + # Assign our tags (if specifed) + if self.__tags: + params['tags'] = ','.join(self.__tags) + + # Append our details into our parameters + params.update({'+{}'.format(k): v for k, v in self.details.items()}) + + # Extend our parameters + params.update(self.url_parameters(privacy=privacy, *args, **kwargs)) + + # A map allows us to map our target types so they can be correctly + # placed back into your URL below. Hence map the 'user' -> '@' + __map = { + OpsgenieCategory.USER: + NotifyOpsgenie.template_tokens['target_user']['prefix'], + OpsgenieCategory.SCHEDULE: + NotifyOpsgenie.template_tokens['target_schedule']['prefix'], + OpsgenieCategory.ESCALATION: + NotifyOpsgenie.template_tokens['target_escalation']['prefix'], + OpsgenieCategory.TEAM: + NotifyOpsgenie.template_tokens['target_team']['prefix'], + } + + return '{schema}://{apikey}/{targets}/?{params}'.format( + schema=self.secure_protocol, + apikey=self.pprint(self.apikey, privacy, safe=''), + targets='/'.join( + [NotifyOpsgenie.quote('{}{}'.format( + __map[x['type']], + x.get('id', x.get('name', x.get('username'))))) + for x in self.targets]), + params=NotifyOpsgenie.urlencode(params)) + + @staticmethod + def parse_url(url): + """ + Parses the URL and returns enough arguments that can allow + us to re-instantiate this object. + + """ + results = NotifyBase.parse_url(url, verify_host=False) + if not results: + # We're done early as we couldn't load the results + return results + + # The API Key is stored in the hostname + results['apikey'] = NotifyOpsgenie.unquote(results['host']) + + # Get our Targets + results['targets'] = NotifyOpsgenie.split_path(results['fullpath']) + + # Add our Meta Detail keys + results['details'] = {NotifyBase.unquote(x): NotifyBase.unquote(y) + for x, y in results['qsd+'].items()} + + if 'priority' in results['qsd'] and len(results['qsd']['priority']): + _map = { + # Letter Assignnments + 'l': OpsgeniePriority.LOW, + 'm': OpsgeniePriority.MODERATE, + 'n': OpsgeniePriority.NORMAL, + 'h': OpsgeniePriority.HIGH, + 'e': OpsgeniePriority.EMERGENCY, + 'lo': OpsgeniePriority.LOW, + 'me': OpsgeniePriority.MODERATE, + 'no': OpsgeniePriority.NORMAL, + 'hi': OpsgeniePriority.HIGH, + 'em': OpsgeniePriority.EMERGENCY, + # Support 3rd Party API Documented Scale + '1': OpsgeniePriority.LOW, + '2': OpsgeniePriority.MODERATE, + '3': OpsgeniePriority.NORMAL, + '4': OpsgeniePriority.HIGH, + '5': OpsgeniePriority.EMERGENCY, + 'p1': OpsgeniePriority.LOW, + 'p2': OpsgeniePriority.MODERATE, + 'p3': OpsgeniePriority.NORMAL, + 'p4': OpsgeniePriority.HIGH, + 'p5': OpsgeniePriority.EMERGENCY, + } + try: + results['priority'] = \ + _map[results['qsd']['priority'][0:2].lower()] + + except KeyError: + # No priority was set + pass + + # Get Batch Boolean (if set) + results['batch'] = \ + parse_bool( + results['qsd'].get( + 'batch', + NotifyOpsgenie.template_args['batch']['default'])) + + if 'apikey' in results['qsd'] and len(results['qsd']['apikey']): + results['apikey'] = \ + NotifyOpsgenie.unquote(results['qsd']['apikey']) + + if 'tags' in results['qsd'] and len(results['qsd']['tags']): + # Extract our tags + results['tags'] = \ + parse_list(NotifyOpsgenie.unquote(results['qsd']['tags'])) + + if 'region' in results['qsd'] and len(results['qsd']['region']): + # Extract our region + results['region_name'] = \ + NotifyOpsgenie.unquote(results['qsd']['region']) + + if 'entity' in results['qsd'] and len(results['qsd']['entity']): + # Extract optional entity field + results['entity'] = \ + NotifyOpsgenie.unquote(results['qsd']['entity']) + + if 'alias' in results['qsd'] and len(results['qsd']['alias']): + # Extract optional alias field + results['alias'] = \ + NotifyOpsgenie.unquote(results['qsd']['alias']) + + # Handle 'to' email address + if 'to' in results['qsd'] and len(results['qsd']['to']): + results['targets'].append(results['qsd']['to']) + + return results diff --git a/libs/apprise/plugins/NotifyParsePlatform.py b/libs/apprise/plugins/NotifyParsePlatform.py new file mode 100644 index 000000000..07cff21d4 --- /dev/null +++ b/libs/apprise/plugins/NotifyParsePlatform.py @@ -0,0 +1,320 @@ +# -*- coding: utf-8 -*- +# +# Copyright (C) 2020 Chris Caron <[email protected]> +# All rights reserved. +# +# This code is licensed under the MIT License. +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files(the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and / or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions : +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. + +# Official API reference: https://developer.gitter.im/docs/user-resource + +import re +import six +import requests +from json import dumps + +from .NotifyBase import NotifyBase +from ..common import NotifyType +from ..utils import validate_regex +from ..AppriseLocale import gettext_lazy as _ + +# Used to break path apart into list of targets +TARGET_LIST_DELIM = re.compile(r'[ \t\r\n,\\/]+') + + +# Priorities +class ParsePlatformDevice(object): + # All Devices + ALL = 'all' + + # Apple IOS (APNS) + IOS = 'ios' + + # Android/Firebase (FCM) + ANDROID = 'android' + + +PARSE_PLATFORM_DEVICES = ( + ParsePlatformDevice.ALL, + ParsePlatformDevice.IOS, + ParsePlatformDevice.ANDROID, +) + + +class NotifyParsePlatform(NotifyBase): + """ + A wrapper for Parse Platform Notifications + """ + + # The default descriptive name associated with the Notification + service_name = 'Parse Platform' + + # The services URL + service_url = ' https://parseplatform.org/' + + # insecure notifications (using http) + protocol = 'parsep' + + # Secure notifications (using https) + secure_protocol = 'parseps' + + # A URL that takes you to the setup/help of the specific protocol + setup_url = 'https://github.com/caronc/apprise/wiki/Notify_parseplatform' + + # Define object templates + templates = ( + '{schema}://{app_id}:{master_key}@{host}', + '{schema}://{app_id}:{master_key}@{host}:{port}', + ) + + # Define our template tokens + template_tokens = dict(NotifyBase.template_tokens, **{ + 'host': { + 'name': _('Hostname'), + 'type': 'string', + 'required': True, + }, + 'port': { + 'name': _('Port'), + 'type': 'int', + 'min': 1, + 'max': 65535, + }, + 'app_id': { + 'name': _('App ID'), + 'type': 'string', + 'private': True, + 'required': True, + }, + 'master_key': { + 'name': _('Master Key'), + 'type': 'string', + 'private': True, + 'required': True, + }, + }) + + # Define our template arguments + template_args = dict(NotifyBase.template_args, **{ + 'device': { + 'name': _('Device'), + 'type': 'choice:string', + 'values': PARSE_PLATFORM_DEVICES, + 'default': ParsePlatformDevice.ALL, + }, + 'app_id': { + 'alias_of': 'app_id', + }, + 'master_key': { + 'alias_of': 'master_key', + }, + }) + + def __init__(self, app_id, master_key, device=None, **kwargs): + """ + Initialize Parse Platform Object + """ + super(NotifyParsePlatform, self).__init__(**kwargs) + + self.fullpath = kwargs.get('fullpath') + if not isinstance(self.fullpath, six.string_types): + self.fullpath = '/' + + # Application ID + self.application_id = validate_regex(app_id) + if not self.application_id: + msg = 'An invalid Parse Platform Application ID ' \ + '({}) was specified.'.format(app_id) + self.logger.warning(msg) + raise TypeError(msg) + + # Master Key + self.master_key = validate_regex(master_key) + if not self.master_key: + msg = 'An invalid Parse Platform Master Key ' \ + '({}) was specified.'.format(master_key) + self.logger.warning(msg) + raise TypeError(msg) + + # Initialize Devices Array + self.devices = [] + + if device: + self.device = device.lower() + if device not in PARSE_PLATFORM_DEVICES: + msg = 'An invalid Parse Platform device ' \ + '({}) was specified.'.format(device) + self.logger.warning(msg) + raise TypeError(msg) + else: + self.device = self.template_args['device']['default'] + + if self.device == ParsePlatformDevice.ALL: + self.devices = [d for d in PARSE_PLATFORM_DEVICES + if d != ParsePlatformDevice.ALL] + else: + # Store our device + self.devices.append(device) + + return + + def send(self, body, title='', notify_type=NotifyType.INFO, **kwargs): + """ + Perform Parse Platform Notification + """ + + # Prepare our headers: + headers = { + 'User-Agent': self.app_id, + 'Content-Type': 'application/json', + 'X-Parse-Application-Id': self.application_id, + 'X-Parse-Master-Key': self.master_key, + } + + # prepare our payload + payload = { + 'where': { + 'deviceType': { + '$in': self.devices, + } + }, + 'data': { + 'title': title, + 'alert': body, + } + } + + # Set our schema + schema = 'https' if self.secure else 'http' + + # Our Notification URL + url = '%s://%s' % (schema, self.host) + if isinstance(self.port, int): + url += ':%d' % self.port + + url += self.fullpath.rstrip('/') + '/parse/push/' + + self.logger.debug('Parse Platform POST URL: %s (cert_verify=%r)' % ( + url, self.verify_certificate, + )) + self.logger.debug('Parse Platform Payload: %s' % str(payload)) + + # Always call throttle before any remote server i/o is made + self.throttle() + + try: + r = requests.post( + url, + data=dumps(payload), + headers=headers, + verify=self.verify_certificate, + ) + if r.status_code != requests.codes.ok: + # We had a problem + status_str = NotifyParsePlatform.\ + http_response_code_lookup(r.status_code) + + self.logger.warning( + 'Failed to send Parse Platform 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 Parse Platform notification.') + + except requests.RequestException as e: + self.logger.warning( + 'A Connection error occured sending Parse Platform ' + 'notification to %s.' % self.host) + self.logger.debug('Socket Exception: %s' % str(e)) + + # Return; we're done + return False + + return True + + def url(self, privacy=False, *args, **kwargs): + """ + Returns the URL built dynamically based on specified arguments. + """ + + # Define any arguments set + params = { + 'device': self.device, + } + + # Extend our parameters + params.update(self.url_parameters(privacy=privacy, *args, **kwargs)) + + default_port = 443 if self.secure else 80 + + return \ + '{schema}://{app_id}:{master_key}@' \ + '{hostname}{port}{fullpath}/?{params}'.format( + schema=self.secure_protocol if self.secure else self.protocol, + app_id=self.pprint(self.application_id, privacy, safe=''), + master_key=self.pprint(self.master_key, privacy, safe=''), + hostname=NotifyParsePlatform.quote(self.host, safe=''), + port='' if self.port is None or self.port == default_port + else ':{}'.format(self.port), + fullpath=NotifyParsePlatform.quote(self.fullpath, safe='/'), + params=NotifyParsePlatform.urlencode(params)) + + @staticmethod + def parse_url(url): + """ + Parses the URL and returns enough arguments that can allow + us to substantiate this object. + + """ + results = NotifyBase.parse_url(url) + + if not results: + # We're done early as we couldn't load the results + return results + + # App ID is retrieved from the user + results['app_id'] = NotifyParsePlatform.unquote(results['user']) + + # Master Key is retrieved from the password + results['master_key'] = \ + NotifyParsePlatform.unquote(results['password']) + + # Device support override + if 'device' in results['qsd'] and len(results['qsd']['device']): + results['device'] = results['qsd']['device'] + + # Allow app_id attribute over-ride + if 'app_id' in results['qsd'] and len(results['qsd']['app_id']): + results['app_id'] = results['qsd']['app_id'] + + # Allow master_key attribute over-ride + if 'master_key' in results['qsd'] \ + and len(results['qsd']['master_key']): + results['master_key'] = results['qsd']['master_key'] + + return results diff --git a/libs/apprise/plugins/NotifyPopcornNotify.py b/libs/apprise/plugins/NotifyPopcornNotify.py index 817915186..7352c6676 100644 --- a/libs/apprise/plugins/NotifyPopcornNotify.py +++ b/libs/apprise/plugins/NotifyPopcornNotify.py @@ -23,20 +23,17 @@ # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN # THE SOFTWARE. -import re import requests from .NotifyBase import NotifyBase from ..common import NotifyType from ..utils import is_email +from ..utils import is_phone_no from ..utils import parse_list from ..utils import parse_bool from ..utils import validate_regex from ..AppriseLocale import gettext_lazy as _ -# Some Phone Number Detection -IS_PHONE_NO = re.compile(r'^\+?(?P<phone>[0-9\s)(+-]+)\s*$') - class NotifyPopcornNotify(NotifyBase): """ @@ -127,19 +124,10 @@ class NotifyPopcornNotify(NotifyBase): for target in parse_list(targets): # Validate targets and drop bad ones: - result = IS_PHONE_NO.match(target) + result = is_phone_no(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) + self.targets.append(result['full']) continue result = is_email(target) diff --git a/libs/apprise/plugins/NotifyProwl.py b/libs/apprise/plugins/NotifyProwl.py index 8341064d3..95673b3af 100644 --- a/libs/apprise/plugins/NotifyProwl.py +++ b/libs/apprise/plugins/NotifyProwl.py @@ -278,15 +278,27 @@ class NotifyProwl(NotifyBase): if 'priority' in results['qsd'] and len(results['qsd']['priority']): _map = { + # Letter Assignments 'l': ProwlPriority.LOW, 'm': ProwlPriority.MODERATE, 'n': ProwlPriority.NORMAL, 'h': ProwlPriority.HIGH, 'e': ProwlPriority.EMERGENCY, + 'lo': ProwlPriority.LOW, + 'me': ProwlPriority.MODERATE, + 'no': ProwlPriority.NORMAL, + 'hi': ProwlPriority.HIGH, + 'em': ProwlPriority.EMERGENCY, + # Support 3rd Party Documented Scale + '-2': ProwlPriority.LOW, + '-1': ProwlPriority.MODERATE, + '0': ProwlPriority.NORMAL, + '1': ProwlPriority.HIGH, + '2': ProwlPriority.EMERGENCY, } try: results['priority'] = \ - _map[results['qsd']['priority'][0].lower()] + _map[results['qsd']['priority'][0:2].lower()] except KeyError: # No priority was set diff --git a/libs/apprise/plugins/NotifyPushBullet.py b/libs/apprise/plugins/NotifyPushBullet.py index 9bae32f96..53240d2e7 100644 --- a/libs/apprise/plugins/NotifyPushBullet.py +++ b/libs/apprise/plugins/NotifyPushBullet.py @@ -367,8 +367,9 @@ class NotifyPushBullet(NotifyBase): except (OSError, IOError) as e: self.logger.warning( - 'An I/O error occurred while reading {}.'.format( - payload.name if payload else 'attachment')) + 'An I/O error occurred while handling {}.'.format( + payload.name if isinstance(payload, AttachBase) + else payload)) self.logger.debug('I/O Exception: %s' % str(e)) return False, response diff --git a/libs/apprise/plugins/NotifyPushover.py b/libs/apprise/plugins/NotifyPushover.py index e9fdb7028..d7f5750f8 100644 --- a/libs/apprise/plugins/NotifyPushover.py +++ b/libs/apprise/plugins/NotifyPushover.py @@ -29,6 +29,7 @@ import requests from .NotifyBase import NotifyBase from ..common import NotifyType +from ..common import NotifyFormat from ..utils import parse_list from ..utils import validate_regex from ..AppriseLocale import gettext_lazy as _ @@ -162,14 +163,12 @@ class NotifyPushover(NotifyBase): 'type': 'string', 'private': True, 'required': True, - 'regex': (r'^[a-z0-9]{30}$', 'i'), }, 'token': { 'name': _('Access Token'), 'type': 'string', 'private': True, 'required': True, - 'regex': (r'^[a-z0-9]{30}$', 'i'), }, 'target_device': { 'name': _('Target Device'), @@ -197,6 +196,16 @@ class NotifyPushover(NotifyBase): 'regex': (r'^[a-z]{1,12}$', 'i'), 'default': PushoverSound.PUSHOVER, }, + 'url': { + 'name': _('URL'), + 'map_to': 'supplemental_url', + 'type': 'string', + }, + 'url_title': { + 'name': _('URL Title'), + 'map_to': 'supplemental_url_title', + 'type': 'string' + }, 'retry': { 'name': _('Retry'), 'type': 'int', @@ -216,15 +225,15 @@ class NotifyPushover(NotifyBase): }) def __init__(self, user_key, token, targets=None, priority=None, - sound=None, retry=None, expire=None, **kwargs): + sound=None, retry=None, expire=None, supplemental_url=None, + supplemental_url_title=None, **kwargs): """ Initialize Pushover Object """ super(NotifyPushover, self).__init__(**kwargs) # Access Token (associated with project) - self.token = validate_regex( - token, *self.template_tokens['token']['regex']) + self.token = validate_regex(token) if not self.token: msg = 'An invalid Pushover Access Token ' \ '({}) was specified.'.format(token) @@ -232,8 +241,7 @@ class NotifyPushover(NotifyBase): raise TypeError(msg) # User Key (associated with project) - self.user_key = validate_regex( - user_key, *self.template_tokens['user_key']['regex']) + self.user_key = validate_regex(user_key) if not self.user_key: msg = 'An invalid Pushover User Key ' \ '({}) was specified.'.format(user_key) @@ -244,6 +252,10 @@ class NotifyPushover(NotifyBase): if len(self.targets) == 0: self.targets = (PUSHOVER_SEND_TO_ALL, ) + # Setup supplemental url + self.supplemental_url = supplemental_url + self.supplemental_url_title = supplemental_url_title + # Setup our sound self.sound = NotifyPushover.default_pushover_sound \ if not isinstance(sound, six.string_types) else sound.lower() @@ -324,6 +336,15 @@ class NotifyPushover(NotifyBase): 'sound': self.sound, } + if self.supplemental_url: + payload['url'] = self.supplemental_url + if self.supplemental_url_title: + payload['url_title'] = self.supplemental_url_title + + if self.notify_format == NotifyFormat.HTML: + # https://pushover.net/api#html + payload['html'] = 1 + if self.priority == PushoverPriority.EMERGENCY: payload.update({'retry': self.retry, 'expire': self.expire}) @@ -568,6 +589,14 @@ class NotifyPushover(NotifyBase): results['sound'] = \ NotifyPushover.unquote(results['qsd']['sound']) + # Get the supplementary url + if 'url' in results['qsd'] and len(results['qsd']['url']): + results['supplemental_url'] = NotifyPushover.unquote( + results['qsd']['url'] + ) + if 'url_title' in results['qsd'] and len(results['qsd']['url_title']): + results['supplemental_url_title'] = results['qsd']['url_title'] + # Get expire and retry if 'expire' in results['qsd'] and len(results['qsd']['expire']): results['expire'] = results['qsd']['expire'] diff --git a/libs/apprise/plugins/NotifyReddit.py b/libs/apprise/plugins/NotifyReddit.py new file mode 100644 index 000000000..2da5da86e --- /dev/null +++ b/libs/apprise/plugins/NotifyReddit.py @@ -0,0 +1,750 @@ +# -*- coding: utf-8 -*- +# +# Copyright (C) 2021 Chris Caron <[email protected]> +# All rights reserved. +# +# This code is licensed under the MIT License. +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files(the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and / or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions : +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. +# +# 1. Visit https://www.reddit.com/prefs/apps and scroll to the bottom +# 2. Click on the button that reads 'are you a developer? create an app...' +# 3. Set the mode to `script`, +# 4. Provide a `name`, `description`, `redirect uri` and save it. +# 5. Once the bot is saved, you'll be given a ID (next to the the bot name) +# and a Secret. + +# The App ID will look something like this: YWARPXajkk645m +# The App Secret will look something like this: YZGKc5YNjq3BsC-bf7oBKalBMeb1xA +# The App will also have a location where you can identify the users +# who have access (identified as Developers) to the app itself. You will +# additionally need these credentials authenticate with. + +# With this information you'll be able to form the URL: +# reddit://{user}:{password}@{app_id}/{app_secret} + +# All of the documentation needed to work with the Reddit API can be found +# here: +# - https://www.reddit.com/dev/api/ +# - https://www.reddit.com/dev/api/#POST_api_submit +# - https://github.com/reddit-archive/reddit/wiki/API +import six +import requests +from json import loads +from datetime import timedelta +from datetime import datetime + +from .NotifyBase import NotifyBase +from ..URLBase import PrivacyMode +from ..common import NotifyFormat +from ..common import NotifyType +from ..utils import parse_list +from ..utils import parse_bool +from ..utils import validate_regex +from ..AppriseLocale import gettext_lazy as _ +from .. import __title__, __version__ + +# Extend HTTP Error Messages +REDDIT_HTTP_ERROR_MAP = { + 401: 'Unauthorized - Invalid Token', +} + + +class RedditMessageKind(object): + """ + Define the kinds of messages supported + """ + # Attempt to auto-detect the type prior to passing along the message to + # Reddit + AUTO = 'auto' + + # A common message + SELF = 'self' + + # A Hyperlink + LINK = 'link' + + +REDDIT_MESSAGE_KINDS = ( + RedditMessageKind.AUTO, + RedditMessageKind.SELF, + RedditMessageKind.LINK, +) + + +class NotifyReddit(NotifyBase): + """ + A wrapper for Notify Reddit Notifications + """ + + # The default descriptive name associated with the Notification + service_name = 'Reddit' + + # The services URL + service_url = 'https://reddit.com' + + # The default secure protocol + secure_protocol = 'reddit' + + # A URL that takes you to the setup/help of the specific protocol + setup_url = 'https://github.com/caronc/apprise/wiki/Notify_reddit' + + # The maximum size of the message + body_maxlen = 6000 + + # Maximum title length as defined by the Reddit API + title_maxlen = 300 + + # Default to markdown + notify_format = NotifyFormat.MARKDOWN + + # The default Notification URL to use + auth_url = 'https://www.reddit.com/api/v1/access_token' + submit_url = 'https://oauth.reddit.com/api/submit' + + # Reddit is kind enough to return how many more requests we're allowed to + # continue to make within it's header response as: + # X-RateLimit-Reset: The epoc time (in seconds) we can expect our + # rate-limit to be reset. + # X-RateLimit-Remaining: an integer identifying how many requests we're + # still allow to make. + request_rate_per_sec = 0 + + # For Tracking Purposes + ratelimit_reset = datetime.utcnow() + + # Default to 1.0 + ratelimit_remaining = 1.0 + + # Taken right from google.auth.helpers: + clock_skew = timedelta(seconds=10) + + # 1 hour in seconds (the lifetime of our token) + access_token_lifetime_sec = timedelta(seconds=3600) + + # Define object templates + templates = ( + '{schema}://{user}:{password}@{app_id}/{app_secret}/{targets}', + ) + + # Define our template arguments + template_tokens = dict(NotifyBase.template_tokens, **{ + 'user': { + 'name': _('User Name'), + 'type': 'string', + 'required': True, + }, + 'password': { + 'name': _('Password'), + 'type': 'string', + 'private': True, + 'required': True, + }, + 'app_id': { + 'name': _('Application ID'), + 'type': 'string', + 'private': True, + 'required': True, + 'regex': (r'^[a-z0-9-]+$', 'i'), + }, + 'app_secret': { + 'name': _('Application Secret'), + 'type': 'string', + 'private': True, + 'required': True, + 'regex': (r'^[a-z0-9-]+$', 'i'), + }, + 'target_subreddit': { + 'name': _('Target Subreddit'), + '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', + }, + 'kind': { + 'name': _('Kind'), + 'type': 'choice:string', + 'values': REDDIT_MESSAGE_KINDS, + 'default': RedditMessageKind.AUTO, + }, + 'flair_id': { + 'name': _('Flair ID'), + 'type': 'string', + 'map_to': 'flair_id', + }, + 'flair_text': { + 'name': _('Flair Text'), + 'type': 'string', + 'map_to': 'flair_text', + }, + 'nsfw': { + 'name': _('NSFW'), + 'type': 'bool', + 'default': False, + 'map_to': 'nsfw', + }, + 'ad': { + 'name': _('Is Ad?'), + 'type': 'bool', + 'default': False, + 'map_to': 'advertisement', + }, + 'replies': { + 'name': _('Send Replies'), + 'type': 'bool', + 'default': True, + 'map_to': 'sendreplies', + }, + 'spoiler': { + 'name': _('Is Spoiler'), + 'type': 'bool', + 'default': False, + 'map_to': 'spoiler', + }, + 'resubmit': { + 'name': _('Resubmit Flag'), + 'type': 'bool', + 'default': False, + 'map_to': 'resubmit', + }, + }) + + def __init__(self, app_id=None, app_secret=None, targets=None, + kind=None, nsfw=False, sendreplies=True, resubmit=False, + spoiler=False, advertisement=False, + flair_id=None, flair_text=None, **kwargs): + """ + Initialize Notify Reddit Object + """ + super(NotifyReddit, self).__init__(**kwargs) + + # Initialize subreddit list + self.subreddits = set() + + # Not Safe For Work Flag + self.nsfw = nsfw + + # Send Replies Flag + self.sendreplies = sendreplies + + # Is Spoiler Flag + self.spoiler = spoiler + + # Resubmit Flag + self.resubmit = resubmit + + # Is Ad? + self.advertisement = advertisement + + # Flair details + self.flair_id = flair_id + self.flair_text = flair_text + + # Our keys we build using the provided content + self.__refresh_token = None + self.__access_token = None + self.__access_token_expiry = datetime.utcnow() + + self.kind = kind.strip().lower() \ + if isinstance(kind, six.string_types) \ + else self.template_args['kind']['default'] + + if self.kind not in REDDIT_MESSAGE_KINDS: + msg = 'An invalid Reddit message kind ({}) was specified'.format( + kind) + self.logger.warning(msg) + raise TypeError(msg) + + self.user = validate_regex(self.user) + if not self.user: + msg = 'An invalid Reddit User ID ' \ + '({}) was specified'.format(self.user) + self.logger.warning(msg) + raise TypeError(msg) + + self.password = validate_regex(self.password) + if not self.password: + msg = 'An invalid Reddit Password ' \ + '({}) was specified'.format(self.password) + self.logger.warning(msg) + raise TypeError(msg) + + self.client_id = validate_regex( + app_id, *self.template_tokens['app_id']['regex']) + if not self.client_id: + msg = 'An invalid Reddit App ID ' \ + '({}) was specified'.format(app_id) + self.logger.warning(msg) + raise TypeError(msg) + + self.client_secret = validate_regex( + app_secret, *self.template_tokens['app_secret']['regex']) + if not self.client_secret: + msg = 'An invalid Reddit App Secret ' \ + '({}) was specified'.format(app_secret) + self.logger.warning(msg) + raise TypeError(msg) + + # Build list of subreddits + self.subreddits = [ + sr.lstrip('#') for sr in parse_list(targets) if sr.lstrip('#')] + + if not self.subreddits: + self.logger.warning( + 'No subreddits were identified to be notified') + return + + def url(self, privacy=False, *args, **kwargs): + """ + Returns the URL built dynamically based on specified arguments. + """ + + # Define any URL parameters + params = { + 'kind': self.kind, + 'ad': 'yes' if self.advertisement else 'no', + 'nsfw': 'yes' if self.nsfw else 'no', + 'resubmit': 'yes' if self.resubmit else 'no', + 'replies': 'yes' if self.sendreplies else 'no', + 'spoiler': 'yes' if self.spoiler else 'no', + } + + # Flair support + if self.flair_id: + params['flair_id'] = self.flair_id + + if self.flair_text: + params['flair_text'] = self.flair_text + + # Extend our parameters + params.update(self.url_parameters(privacy=privacy, *args, **kwargs)) + + return '{schema}://{user}:{password}@{app_id}/{app_secret}' \ + '/{targets}/?{params}'.format( + schema=self.secure_protocol, + user=NotifyReddit.quote(self.user, safe=''), + password=self.pprint( + self.password, privacy, mode=PrivacyMode.Secret, safe=''), + app_id=self.pprint( + self.client_id, privacy, mode=PrivacyMode.Secret, safe=''), + app_secret=self.pprint( + self.client_secret, privacy, mode=PrivacyMode.Secret, + safe=''), + targets='/'.join( + [NotifyReddit.quote(x, safe='') for x in self.subreddits]), + params=NotifyReddit.urlencode(params), + ) + + def login(self): + """ + A simple wrapper to authenticate with the Reddit Server + """ + + # Prepare our payload + payload = { + 'grant_type': 'password', + 'username': self.user, + 'password': self.password, + } + + # Enforce a False flag setting before calling _fetch() + self.__access_token = False + + # Send Login Information + postokay, response = self._fetch( + self.auth_url, + payload=payload, + ) + + if not postokay or not response: + # Setting this variable to False as a way of letting us know + # we failed to authenticate on our last attempt + self.__access_token = False + return False + + # Our response object looks like this (content has been altered for + # presentation purposes): + # { + # "access_token": Your access token, + # "token_type": "bearer", + # "expires_in": Unix Epoch Seconds, + # "scope": A scope string, + # "refresh_token": Your refresh token + # } + + # Acquire our token + self.__access_token = response.get('access_token') + + # Handle other optional arguments we can use + if 'expires_in' in response: + delta = timedelta(seconds=int(response['expires_in'])) + self.__access_token_expiry = \ + delta + datetime.utcnow() - self.clock_skew + else: + self.__access_token_expiry = self.access_token_lifetime_sec + \ + datetime.utcnow() - self.clock_skew + + # The Refresh Token + self.__refresh_token = response.get( + 'refresh_token', self.__refresh_token) + + if self.__access_token: + self.logger.info('Authenticated to Reddit as {}'.format(self.user)) + return True + + self.logger.warning( + 'Failed to authenticate to Reddit as {}'.format(self.user)) + + # Mark our failure + return False + + def send(self, body, title='', notify_type=NotifyType.INFO, **kwargs): + """ + Perform Reddit Notification + """ + + # error tracking (used for function return) + has_error = False + + if not self.__access_token and not self.login(): + # We failed to authenticate - we're done + return False + + if not len(self.subreddits): + # We have nothing to notify; we're done + self.logger.warning('There are no Reddit targets to notify') + return False + + # Prepare our Message Type/Kind + if self.kind == RedditMessageKind.AUTO: + parsed = NotifyBase.parse_url(body) + # Detect a link + if parsed and parsed.get('schema', '').startswith('http') \ + and parsed.get('host'): + kind = RedditMessageKind.LINK + + else: + kind = RedditMessageKind.SELF + else: + kind = self.kind + + # Create a copy of the subreddits list + subreddits = list(self.subreddits) + while len(subreddits) > 0: + # Retrieve our subreddit + subreddit = subreddits.pop() + + # Prepare our payload + payload = { + 'ad': True if self.advertisement else False, + 'api_type': 'json', + 'extension': 'json', + 'sr': subreddit, + 'title': title, + 'kind': kind, + 'nsfw': True if self.nsfw else False, + 'resubmit': True if self.resubmit else False, + 'sendreplies': True if self.sendreplies else False, + 'spoiler': True if self.spoiler else False, + } + + if self.flair_id: + payload['flair_id'] = self.flair_id + + if self.flair_text: + payload['flair_text'] = self.flair_text + + if kind == RedditMessageKind.LINK: + payload.update({ + 'url': body, + }) + else: + payload.update({ + 'text': body, + }) + + postokay, response = self._fetch(self.submit_url, payload=payload) + # only toggle has_error flag if we had an error + if not postokay: + # Mark our failure + has_error = True + continue + + # If we reach here, we were successful + self.logger.info( + 'Sent Reddit notification to {}'.format( + subreddit)) + + return not has_error + + def _fetch(self, url, payload=None): + """ + Wrapper to Reddit API requests object + """ + + # use what was specified, otherwise build headers dynamically + headers = { + 'User-Agent': '{} v{}'.format(__title__, __version__) + } + + if self.__access_token: + # Set our token + headers['Authorization'] = 'Bearer {}'.format(self.__access_token) + + # Prepare our url + url = self.submit_url if self.__access_token else self.auth_url + + # Some Debug Logging + self.logger.debug('Reddit POST URL: {} (cert_verify={})'.format( + url, self.verify_certificate)) + self.logger.debug('Reddit 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 + # Gitter server. One would hope we're on NTP and our clocks are + # the same allowing this to role smoothly: + + now = datetime.utcnow() + if now < self.ratelimit_reset: + # We need to throttle for the difference in seconds + 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) + + # Initialize a default value for our content value + content = {} + + # acquire our request mode + try: + r = requests.post( + url, + data=payload, + auth=None if self.__access_token + else (self.client_id, self.client_secret), + headers=headers, + verify=self.verify_certificate, + timeout=self.request_timeout, + ) + + # We attempt to login again and retry the original request + # if we aren't in the process of handling a login already + if r.status_code != requests.codes.ok \ + and self.__access_token and url != self.auth_url: + + # We had a problem + status_str = \ + NotifyReddit.http_response_code_lookup( + r.status_code, REDDIT_HTTP_ERROR_MAP) + + self.logger.debug( + 'Taking countermeasures after failed to send to Reddit ' + '{}: {}error={}'.format( + url, + ', ' if status_str else '', + r.status_code)) + + self.logger.debug( + 'Response Details:\r\n{}'.format(r.content)) + + # We failed to authenticate with our token; login one more + # time and retry this original request + if not self.login(): + return (False, {}) + + # Try again + r = requests.post( + url, + data=payload, + headers=headers, + verify=self.verify_certificate, + timeout=self.request_timeout + ) + + # Get our JSON content if it's possible + try: + content = loads(r.content) + + except (TypeError, ValueError, AttributeError): + # TypeError = r.content is not a String + # ValueError = r.content is Unparsable + # AttributeError = r.content is None + + # We had a problem + status_str = \ + NotifyReddit.http_response_code_lookup( + r.status_code, REDDIT_HTTP_ERROR_MAP) + + # Reddit always returns a JSON response + self.logger.warning( + 'Failed to send to Reddit after countermeasures {}: ' + '{}error={}'.format( + url, + ', ' if status_str else '', + r.status_code)) + + self.logger.debug( + 'Response Details:\r\n{}'.format(r.content)) + return (False, {}) + + if r.status_code != requests.codes.ok: + # We had a problem + status_str = \ + NotifyReddit.http_response_code_lookup( + r.status_code, REDDIT_HTTP_ERROR_MAP) + + self.logger.warning( + 'Failed to send to Reddit {}: ' + '{}error={}'.format( + url, + ', ' if status_str else '', + r.status_code)) + + self.logger.debug( + 'Response Details:\r\n{}'.format(r.content)) + + # Mark our failure + return (False, content) + + errors = [] if not content else \ + content.get('json', {}).get('errors', []) + if errors: + self.logger.warning( + 'Failed to send to Reddit {}: ' + '{}'.format( + url, + str(errors))) + + self.logger.debug( + 'Response Details:\r\n{}'.format(r.content)) + + # Mark our failure + return (False, content) + + try: + # Store our rate limiting (if provided) + self.ratelimit_remaining = \ + float(r.headers.get( + 'X-RateLimit-Remaining')) + self.ratelimit_reset = datetime.utcfromtimestamp( + int(r.headers.get('X-RateLimit-Reset'))) + + except (TypeError, ValueError): + # This is returned if we could not retrieve this information + # gracefully accept this state and move on + pass + + except requests.RequestException as e: + self.logger.warning( + 'Exception received when sending Reddit to {}'. + format(url)) + self.logger.debug('Socket Exception: %s' % str(e)) + + # Mark our failure + return (False, content) + + return (True, content) + + @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 + + # Acquire our targets + results['targets'] = NotifyReddit.split_path(results['fullpath']) + + # Kind override + if 'kind' in results['qsd'] and results['qsd']['kind']: + results['kind'] = NotifyReddit.unquote( + results['qsd']['kind'].strip().lower()) + else: + results['kind'] = RedditMessageKind.AUTO + + # Is an Ad? + results['ad'] = \ + parse_bool(results['qsd'].get('ad', False)) + + # Get Not Safe For Work (NSFW) Flag + results['nsfw'] = \ + parse_bool(results['qsd'].get('nsfw', False)) + + # Send Replies Flag + results['replies'] = \ + parse_bool(results['qsd'].get('replies', True)) + + # Resubmit Flag + results['resubmit'] = \ + parse_bool(results['qsd'].get('resubmit', False)) + + # Is Spoiler Flag + results['spoiler'] = \ + parse_bool(results['qsd'].get('spoiler', False)) + + if 'flair_text' in results['qsd']: + results['flair_text'] = \ + NotifyReddit.unquote(results['qsd']['flair_text']) + + if 'flair_id' in results['qsd']: + results['flair_id'] = \ + NotifyReddit.unquote(results['qsd']['flair_id']) + + # The 'to' makes it easier to use yaml configuration + if 'to' in results['qsd'] and len(results['qsd']['to']): + results['targets'] += \ + NotifyReddit.parse_list(results['qsd']['to']) + + if 'app_id' in results['qsd']: + results['app_id'] = \ + NotifyReddit.unquote(results['qsd']['app_id']) + else: + # The App/Bot ID is the hostname + results['app_id'] = NotifyReddit.unquote(results['host']) + + if 'app_secret' in results['qsd']: + results['app_secret'] = \ + NotifyReddit.unquote(results['qsd']['app_secret']) + else: + # The first target identified is the App secret + results['app_secret'] = \ + None if not results['targets'] else results['targets'].pop(0) + + return results diff --git a/libs/apprise/plugins/NotifyRocketChat.py b/libs/apprise/plugins/NotifyRocketChat.py index 9beda2564..9131ceeff 100644 --- a/libs/apprise/plugins/NotifyRocketChat.py +++ b/libs/apprise/plugins/NotifyRocketChat.py @@ -174,14 +174,17 @@ class NotifyRocketChat(NotifyBase): 'avatar': { 'name': _('Use Avatar'), 'type': 'bool', - 'default': True, + 'default': False, + }, + 'webhook': { + 'alias_of': 'webhook', }, 'to': { 'alias_of': 'targets', }, }) - def __init__(self, webhook=None, targets=None, mode=None, avatar=True, + def __init__(self, webhook=None, targets=None, mode=None, avatar=None, **kwargs): """ Initialize Notify Rocket.Chat Object @@ -209,9 +212,6 @@ class NotifyRocketChat(NotifyBase): # Assign our webhook (if defined) self.webhook = webhook - # Place an avatar image to associate with our content - self.avatar = avatar - # Used to track token headers upon authentication (if successful) # This is only used if not on webhook mode self.headers = {} @@ -278,6 +278,22 @@ class NotifyRocketChat(NotifyBase): self.logger.warning(msg) raise TypeError(msg) + # Prepare our avatar setting + # - if specified; that trumps all + # - if not specified and we're dealing with a basic setup, the Avatar + # is disabled by default. This is because if the account doesn't + # have the bot flag set on it it won't work as documented here: + # https://developer.rocket.chat/api/rest-api/endpoints\ + # /team-collaboration-endpoints/chat/postmessage + # - Otherwise if we're a webhook, we enable the avatar by default + # (if not otherwise specified) since it will work nicely. + # Place an avatar image to associate with our content + if self.mode == RocketChatAuthMode.BASIC: + self.avatar = False if avatar is None else avatar + + else: # self.mode == RocketChatAuthMode.WEBHOOK: + self.avatar = True if avatar is None else avatar + return def url(self, privacy=False, *args, **kwargs): @@ -367,11 +383,6 @@ class NotifyRocketChat(NotifyBase): # Initiaize our error tracking has_error = False - headers = { - 'User-Agent': self.app_id, - 'Content-Type': 'application/json', - } - while len(targets): # Retrieve our target target = targets.pop(0) @@ -380,8 +391,7 @@ class NotifyRocketChat(NotifyBase): payload['channel'] = target if not self._send( - dumps(payload), notify_type=notify_type, path=path, - headers=headers, **kwargs): + payload, notify_type=notify_type, path=path, **kwargs): # toggle flag has_error = True @@ -400,21 +410,24 @@ class NotifyRocketChat(NotifyBase): return False # prepare JSON Object - payload = self._payload(body, title, notify_type) + _payload = self._payload(body, title, notify_type) # Initiaize our error tracking has_error = False + # Build our list of channels/rooms/users (if any identified) + channels = ['@{}'.format(u) for u in self.users] + channels.extend(['#{}'.format(c) for c in self.channels]) + # Create a copy of our channels to notify against - channels = list(self.channels) - _payload = payload.copy() + payload = _payload.copy() while len(channels) > 0: # Get Channel channel = channels.pop(0) - _payload['channel'] = channel + payload['channel'] = channel if not self._send( - _payload, notify_type=notify_type, headers=self.headers, + payload, notify_type=notify_type, headers=self.headers, **kwargs): # toggle flag @@ -422,11 +435,11 @@ class NotifyRocketChat(NotifyBase): # Create a copy of our room id's to notify against rooms = list(self.rooms) - _payload = payload.copy() + payload = _payload.copy() while len(rooms): # Get Room room = rooms.pop(0) - _payload['roomId'] = room + payload['roomId'] = room if not self._send( payload, notify_type=notify_type, headers=self.headers, @@ -451,13 +464,13 @@ class NotifyRocketChat(NotifyBase): # apply our images if they're set to be displayed image_url = self.image_url(notify_type) - if self.avatar: + if self.avatar and image_url: payload['avatar'] = image_url return payload def _send(self, payload, notify_type, path='api/v1/chat.postMessage', - headers=None, **kwargs): + headers={}, **kwargs): """ Perform Notify Rocket.Chat Notification """ @@ -468,13 +481,19 @@ class NotifyRocketChat(NotifyBase): api_url, self.verify_certificate)) self.logger.debug('Rocket.Chat Payload: %s' % str(payload)) + # Apply minimum headers + headers.update({ + 'User-Agent': self.app_id, + 'Content-Type': 'application/json', + }) + # Always call throttle before any remote server i/o is made self.throttle() try: r = requests.post( api_url, - data=payload, + data=dumps(payload), headers=headers, verify=self.verify_certificate, timeout=self.request_timeout, @@ -691,8 +710,8 @@ class NotifyRocketChat(NotifyBase): NotifyRocketChat.unquote(results['qsd']['mode']) # avatar icon - results['avatar'] = \ - parse_bool(results['qsd'].get('avatar', True)) + if 'avatar' in results['qsd'] and len(results['qsd']['avatar']): + results['avatar'] = parse_bool(results['qsd'].get('avatar', True)) # The 'to' makes it easier to use yaml configuration if 'to' in results['qsd'] and len(results['qsd']['to']): diff --git a/libs/apprise/plugins/NotifySMTP2Go.py b/libs/apprise/plugins/NotifySMTP2Go.py new file mode 100644 index 000000000..341ad8a62 --- /dev/null +++ b/libs/apprise/plugins/NotifySMTP2Go.py @@ -0,0 +1,584 @@ +# -*- coding: utf-8 -*- +# +# Copyright (C) 2021 Chris Caron <[email protected]> +# All rights reserved. +# +# This code is licensed under the MIT License. +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files(the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and / or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions : +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. + +# Signup @ https://smtp2go.com (free accounts available) +# +# From your dashboard, you can generate an API Key if you haven't already +# at https://app.smtp2go.com/settings/apikeys/ + +# The API Key from here which will look something like: +# api-60F0DD0AB5BA11ABA421F23C91C88EF4 +# +# Knowing this, you can buid your smtp2go url as follows: +# smtp2go://{user}@{domain}/{apikey} +# smtp2go://{user}@{domain}/{apikey}/{email} +# +# You can email as many addresses as you want as: +# smtp2go://{user}@{domain}/{apikey}/{email1}/{email2}/{emailN} +# +# The {user}@{domain} effectively assembles the 'from' email address +# the email will be transmitted from. If no email address is specified +# then it will also become the 'to' address as well. +# +import base64 +import requests +from json import dumps +from email.utils import formataddr +from .NotifyBase import NotifyBase +from ..common import NotifyType +from ..common import NotifyFormat +from ..utils import parse_emails +from ..utils import parse_bool +from ..utils import is_email +from ..utils import validate_regex +from ..AppriseLocale import gettext_lazy as _ + +SMTP2GO_HTTP_ERROR_MAP = { + 429: 'To many requests.', +} + + +class NotifySMTP2Go(NotifyBase): + """ + A wrapper for SMTP2Go Notifications + """ + + # The default descriptive name associated with the Notification + service_name = 'SMTP2Go' + + # The services URL + service_url = 'https://www.smtp2go.com/' + + # All notification requests are secure + secure_protocol = 'smtp2go' + + # SMTP2Go advertises they 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_smtp2go' + + # Notify URL + notify_url = 'https://api.smtp2go.com/v3/email/send' + + # Default Notify Format + notify_format = NotifyFormat.HTML + + # The maximum amount of emails that can reside within a single + # batch transfer + default_batch_size = 100 + + # Define object templates + templates = ( + '{schema}://{user}@{host}:{apikey}/', + '{schema}://{user}@{host}:{apikey}/{targets}', + ) + + # Define our template tokens + template_tokens = dict(NotifyBase.template_tokens, **{ + 'user': { + 'name': _('User Name'), + 'type': 'string', + 'required': True, + }, + 'host': { + 'name': _('Domain'), + 'type': 'string', + 'required': True, + }, + 'apikey': { + 'name': _('API Key'), + 'type': 'string', + 'private': True, + 'required': True, + }, + 'targets': { + 'name': _('Target Emails'), + 'type': 'list:string', + }, + }) + + # Define our template arguments + template_args = dict(NotifyBase.template_args, **{ + 'name': { + 'name': _('From Name'), + 'type': 'string', + 'map_to': 'from_name', + }, + 'to': { + 'alias_of': 'targets', + }, + 'cc': { + 'name': _('Carbon Copy'), + 'type': 'list:string', + }, + 'bcc': { + 'name': _('Blind Carbon Copy'), + 'type': 'list:string', + }, + 'batch': { + 'name': _('Batch Mode'), + 'type': 'bool', + 'default': False, + }, + }) + + # Define any kwargs we're using + template_kwargs = { + 'headers': { + 'name': _('Email Header'), + 'prefix': '+', + }, + } + + def __init__(self, apikey, targets, cc=None, bcc=None, from_name=None, + headers=None, batch=False, **kwargs): + """ + Initialize SMTP2Go Object + """ + super(NotifySMTP2Go, self).__init__(**kwargs) + + # API Key (associated with project) + self.apikey = validate_regex(apikey) + if not self.apikey: + msg = 'An invalid SMTP2Go API Key ' \ + '({}) was specified.'.format(apikey) + self.logger.warning(msg) + raise TypeError(msg) + + # Validate our username + if not self.user: + msg = 'No SMTP2Go username was specified.' + self.logger.warning(msg) + raise TypeError(msg) + + # Acquire Email 'To' + self.targets = list() + + # Acquire Carbon Copies + self.cc = set() + + # Acquire Blind Carbon Copies + self.bcc = set() + + # For tracking our email -> name lookups + self.names = {} + + self.headers = {} + if headers: + # Store our extra headers + self.headers.update(headers) + + # Prepare Batch Mode Flag + self.batch = batch + + # Get our From username (if specified) + self.from_name = from_name + + # Get our from email address + self.from_addr = '{user}@{host}'.format(user=self.user, host=self.host) + + if not is_email(self.from_addr): + # Parse Source domain based on from_addr + msg = 'Invalid ~From~ email format: {}'.format(self.from_addr) + self.logger.warning(msg) + raise TypeError(msg) + + if targets: + # Validate recipients (to:) and drop bad ones: + for recipient in parse_emails(targets): + result = is_email(recipient) + if result: + self.targets.append( + (result['name'] if result['name'] else False, + result['full_email'])) + continue + + self.logger.warning( + 'Dropped invalid To email ' + '({}) specified.'.format(recipient), + ) + + else: + # If our target email list is empty we want to add ourselves to it + self.targets.append( + (self.from_name if self.from_name else False, self.from_addr)) + + # Validate recipients (cc:) and drop bad ones: + for recipient in parse_emails(cc): + email = is_email(recipient) + if email: + self.cc.add(email['full_email']) + + # Index our name (if one exists) + self.names[email['full_email']] = \ + email['name'] if email['name'] else False + continue + + self.logger.warning( + 'Dropped invalid Carbon Copy email ' + '({}) specified.'.format(recipient), + ) + + # Validate recipients (bcc:) and drop bad ones: + for recipient in parse_emails(bcc): + email = is_email(recipient) + if email: + self.bcc.add(email['full_email']) + + # Index our name (if one exists) + self.names[email['full_email']] = \ + email['name'] if email['name'] else False + continue + + self.logger.warning( + 'Dropped invalid Blind Carbon Copy email ' + '({}) specified.'.format(recipient), + ) + + def send(self, body, title='', notify_type=NotifyType.INFO, attach=None, + **kwargs): + """ + Perform SMTP2Go Notification + """ + + if not self.targets: + # There is no one to email; we're done + self.logger.warning( + 'There are no Email recipients to notify') + return False + + # error tracking (used for function return) + has_error = False + + # Send in batches if identified to do so + batch_size = 1 if not self.batch else self.default_batch_size + + # Prepare our headers + headers = { + 'User-Agent': self.app_id, + 'Accept': 'application/json', + 'Content-Type': 'application/json', + } + + # Track our potential attachments + attachments = [] + + if attach: + for idx, attachment in enumerate(attach): + # 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: + 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, + 'fileblob': base64.b64encode(f.read()) + .decode('utf-8'), + 'mimetype': 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 + + try: + sender = formataddr( + (self.from_name if self.from_name else False, + self.from_addr), charset='utf-8') + + except TypeError: + # Python v2.x Support (no charset keyword) + # Format our cc addresses to support the Name field + sender = formataddr( + (self.from_name if self.from_name else False, + self.from_addr)) + + # Prepare our payload + payload = { + # API Key + 'api_key': self.apikey, + + # Base payload options + 'sender': sender, + 'subject': title, + + # our To array + 'to': [], + } + + if attachments: + payload['attachments'] = attachments + + if self.notify_format == NotifyFormat.HTML: + payload['html_body'] = body + + else: + payload['text_body'] = body + + # Create a copy of the targets list + emails = list(self.targets) + + for index in range(0, len(emails), batch_size): + # Initialize our cc list + cc = (self.cc - self.bcc) + + # Initialize our bcc list + bcc = set(self.bcc) + + # Initialize our to list + to = list() + + for to_addr in self.targets[index:index + batch_size]: + # Strip target out of cc list if in To + cc = (cc - set([to_addr[1]])) + + # Strip target out of bcc list if in To + bcc = (bcc - set([to_addr[1]])) + + try: + # Prepare our to + to.append(formataddr(to_addr, charset='utf-8')) + + except TypeError: + # Python v2.x Support (no charset keyword) + # Format our cc addresses to support the Name field + + # Prepare our to + to.append(formataddr(to_addr)) + + # Prepare our To + payload['to'] = to + + if cc: + try: + # Format our cc addresses to support the Name field + payload['cc'] = [formataddr( + (self.names.get(addr, False), addr), charset='utf-8') + for addr in cc] + + except TypeError: + # Python v2.x Support (no charset keyword) + # Format our cc addresses to support the Name field + payload['cc'] = [formataddr( # pragma: no branch + (self.names.get(addr, False), addr)) + for addr in cc] + + # Format our bcc addresses to support the Name field + if bcc: + # set our bcc variable (convert to list first so it's + # JSON serializable) + payload['bcc'] = list(bcc) + + # Store our header entries if defined into the payload + # in their payload + if self.headers: + payload['custom_headers'] = \ + [{'header': k, 'value': v} + for k, v in self.headers.items()] + + # Some Debug Logging + self.logger.debug('SMTP2Go POST URL: {} (cert_verify={})'.format( + self.notify_url, self.verify_certificate)) + self.logger.debug('SMTP2Go Payload: {}' .format(payload)) + + # For logging output of success and errors; we get a head count + # of our outbound details: + verbose_dest = ', '.join( + [x[1] for x in self.targets[index:index + batch_size]]) \ + if len(self.targets[index:index + batch_size]) <= 3 \ + else '{} recipients'.format( + len(self.targets[index:index + batch_size])) + + # 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 = \ + NotifyBase.http_response_code_lookup( + r.status_code, SMTP2GO_HTTP_ERROR_MAP) + + self.logger.warning( + 'Failed to send SMTP2Go notification to {}: ' + '{}{}error={}.'.format( + verbose_dest, + 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 SMTP2Go notification to {}.'.format( + verbose_dest)) + + except requests.RequestException as e: + self.logger.warning( + 'A Connection error occurred sending SMTP2Go:%s ' % ( + verbose_dest) + 'notification.' + ) + self.logger.debug('Socket Exception: %s' % str(e)) + + # Mark our failure + has_error = True + continue + + except (OSError, IOError) as e: + self.logger.warning( + 'An I/O error occurred while reading attachments') + self.logger.debug('I/O 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', + } + + # Append our headers into our parameters + params.update({'+{}'.format(k): v for k, v in self.headers.items()}) + + # Extend our parameters + params.update(self.url_parameters(privacy=privacy, *args, **kwargs)) + + if self.from_name is not None: + # from_name specified; pass it back on the url + params['name'] = self.from_name + + if self.cc: + # Handle our Carbon Copy Addresses + params['cc'] = ','.join( + ['{}{}'.format( + '' if not e not in self.names + else '{}:'.format(self.names[e]), e) for e in self.cc]) + + if self.bcc: + # Handle our Blind Carbon Copy Addresses + params['bcc'] = ','.join(self.bcc) + + # a simple boolean check as to whether we display our target emails + # or not + has_targets = \ + not (len(self.targets) == 1 + and self.targets[0][1] == self.from_addr) + + return '{schema}://{user}@{host}/{apikey}/{targets}?{params}'.format( + schema=self.secure_protocol, + host=self.host, + user=NotifySMTP2Go.quote(self.user, safe=''), + apikey=self.pprint(self.apikey, privacy, safe=''), + targets='' if not has_targets else '/'.join( + [NotifySMTP2Go.quote('{}{}'.format( + '' if not e[0] else '{}:'.format(e[0]), e[1]), + safe='') for e in self.targets]), + params=NotifySMTP2Go.urlencode(params)) + + @staticmethod + def parse_url(url): + """ + Parses the URL and returns enough arguments that can allow + us to re-instantiate this object. + + """ + results = NotifyBase.parse_url(url, verify_host=False) + if not results: + # We're done early as we couldn't load the results + return results + + # Get our entries; split_path() looks after unquoting content for us + # by default + results['targets'] = NotifySMTP2Go.split_path(results['fullpath']) + + # Our very first entry is reserved for our api key + try: + results['apikey'] = results['targets'].pop(0) + + except IndexError: + # We're done - no API Key found + results['apikey'] = None + + if 'name' in results['qsd'] and len(results['qsd']['name']): + # Extract from name to associate with from address + results['from_name'] = \ + NotifySMTP2Go.unquote(results['qsd']['name']) + + # Handle 'to' email address + if 'to' in results['qsd'] and len(results['qsd']['to']): + results['targets'].append(results['qsd']['to']) + + # Handle Carbon Copy Addresses + if 'cc' in results['qsd'] and len(results['qsd']['cc']): + results['cc'] = results['qsd']['cc'] + + # Handle Blind Carbon Copy Addresses + if 'bcc' in results['qsd'] and len(results['qsd']['bcc']): + results['bcc'] = results['qsd']['bcc'] + + # Add our Meta Headers that the user can provide with their outbound + # emails + results['headers'] = {NotifyBase.unquote(x): NotifyBase.unquote(y) + for x, y in results['qsd+'].items()} + + # Get Batch Mode Flag + results['batch'] = \ + parse_bool(results['qsd'].get( + 'batch', NotifySMTP2Go.template_args['batch']['default'])) + + return results diff --git a/libs/apprise/plugins/NotifySNS.py b/libs/apprise/plugins/NotifySNS.py index adbbdfbb3..3cc15a567 100644 --- a/libs/apprise/plugins/NotifySNS.py +++ b/libs/apprise/plugins/NotifySNS.py @@ -35,13 +35,11 @@ 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_list from ..utils import validate_regex from ..AppriseLocale import gettext_lazy as _ -# Some Phone Number Detection -IS_PHONE_NO = re.compile(r'^\+?(?P<phone>[0-9\s)(+-]+)\s*$') - # Topic Detection # Summary: 256 Characters max, only alpha/numeric plus underscore (_) and # dash (-) additionally allowed. @@ -198,24 +196,10 @@ class NotifySNS(NotifyBase): self.aws_auth_algorithm = 'AWS4-HMAC-SHA256' self.aws_auth_request = 'aws4_request' - # Get our targets - targets = parse_list(targets) - # Validate targets and drop bad ones: - for target in targets: - result = IS_PHONE_NO.match(target) + for target in parse_list(targets): + result = is_phone_no(target) if result: - # Further check our phone # for it's digit count - # if it's less than 10, then we can assume it's - # a poorly specified phone no and spit a warning - result = ''.join(re.findall(r'\d+', result.group('phone'))) - if len(result) < 11 or len(result) > 14: - self.logger.warning( - 'Dropped invalid phone # ' - '(%s) specified.' % target, - ) - continue - # store valid phone number self.phone.append('+{}'.format(result)) continue @@ -231,12 +215,6 @@ class NotifySNS(NotifyBase): '(%s) specified.' % target, ) - if len(self.phone) == 0 and len(self.topics) == 0: - # We have a bot token and no target(s) to message - msg = 'No AWS targets to notify.' - self.logger.warning(msg) - raise TypeError(msg) - return def send(self, body, title='', notify_type=NotifyType.INFO, **kwargs): @@ -244,6 +222,11 @@ class NotifySNS(NotifyBase): wrapper to send_notification since we can alert more then one channel """ + if len(self.phone) == 0 and len(self.topics) == 0: + # We have a bot token and no target(s) to message + self.logger.warning('No AWS targets to notify.') + return False + # Initiaize our error tracking error_count = 0 @@ -361,7 +344,7 @@ class NotifySNS(NotifyBase): self.logger.debug('Response Details:\r\n{}'.format(r.content)) - return (False, NotifySNS.aws_response_to_dict(r.content)) + return (False, NotifySNS.aws_response_to_dict(r.text)) else: self.logger.info( @@ -375,7 +358,7 @@ class NotifySNS(NotifyBase): self.logger.debug('Socket Exception: %s' % str(e)) return (False, NotifySNS.aws_response_to_dict(None)) - return (True, NotifySNS.aws_response_to_dict(r.content)) + return (True, NotifySNS.aws_response_to_dict(r.text)) def aws_prepare_request(self, payload, reference=None): """ diff --git a/libs/apprise/plugins/NotifySimplePush.py b/libs/apprise/plugins/NotifySimplePush.py index dd192e794..400216e72 100644 --- a/libs/apprise/plugins/NotifySimplePush.py +++ b/libs/apprise/plugins/NotifySimplePush.py @@ -32,8 +32,8 @@ from ..common import NotifyType from ..utils import validate_regex from ..AppriseLocale import gettext_lazy as _ -# Default our global support flag -CRYPTOGRAPHY_AVAILABLE = False +from base64 import urlsafe_b64encode +import hashlib try: from cryptography.hazmat.primitives import padding @@ -41,15 +41,13 @@ try: from cryptography.hazmat.primitives.ciphers import algorithms from cryptography.hazmat.primitives.ciphers import modes from cryptography.hazmat.backends import default_backend - from base64 import urlsafe_b64encode - import hashlib - CRYPTOGRAPHY_AVAILABLE = True + # We're good to go! + NOTIFY_SIMPLEPUSH_ENABLED = True except ImportError: - # no problem; this just means the added encryption functionality isn't - # available. You can still send a SimplePush message - pass + # cryptography is required in order for this package to work + NOTIFY_SIMPLEPUSH_ENABLED = False class NotifySimplePush(NotifyBase): @@ -57,6 +55,14 @@ class NotifySimplePush(NotifyBase): A wrapper for SimplePush Notifications """ + # Set our global enabled flag + enabled = NOTIFY_SIMPLEPUSH_ENABLED + + requirements = { + # Define our required packaging in order to work + 'packages_required': 'cryptography' + } + # The default descriptive name associated with the Notification service_name = 'SimplePush' @@ -181,15 +187,6 @@ class NotifySimplePush(NotifyBase): Perform SimplePush Notification """ - # Encrypt Message (providing support is available) - if self.password and self.user and not CRYPTOGRAPHY_AVAILABLE: - # Provide the end user at least some notification that they're - # not getting what they asked for - self.logger.warning( - "Authenticated SimplePush Notifications are not supported by " - "this system; `pip install cryptography`.") - return False - headers = { 'User-Agent': self.app_id, 'Content-type': "application/x-www-form-urlencoded", @@ -200,7 +197,7 @@ class NotifySimplePush(NotifyBase): 'key': self.apikey, } - if self.password and self.user and CRYPTOGRAPHY_AVAILABLE: + if self.password and self.user: body = self._encrypt(body) title = self._encrypt(title) payload.update({ diff --git a/libs/apprise/plugins/NotifySinch.py b/libs/apprise/plugins/NotifySinch.py index c3cc32675..61ec452d7 100644 --- a/libs/apprise/plugins/NotifySinch.py +++ b/libs/apprise/plugins/NotifySinch.py @@ -33,7 +33,6 @@ # from). Activated phone numbers can be found on your dashboard here: # - https://dashboard.sinch.com/numbers/your-numbers/numbers # -import re import six import requests import json @@ -41,15 +40,12 @@ import json from .NotifyBase import NotifyBase from ..URLBase import PrivacyMode from ..common import NotifyType -from ..utils import parse_list +from ..utils import is_phone_no +from ..utils import parse_phone_no from ..utils import validate_regex from ..AppriseLocale import gettext_lazy as _ -# Some Phone Number Detection -IS_PHONE_NO = re.compile(r'^\+?(?P<phone>[0-9\s)(+-]+)\s*$') - - class SinchRegion(object): """ Defines the Sinch Server Regions @@ -194,15 +190,6 @@ class NotifySinch(NotifyBase): self.logger.warning(msg) raise TypeError(msg) - # The Source Phone # and/or short-code - self.source = source - - if not IS_PHONE_NO.match(self.source): - msg = 'The Account (From) Phone # or Short-code specified ' \ - '({}) is invalid.'.format(source) - self.logger.warning(msg) - raise TypeError(msg) - # Setup our region self.region = self.template_args['region']['default'] \ if not isinstance(region, six.string_types) else region.lower() @@ -211,8 +198,16 @@ class NotifySinch(NotifyBase): self.logger.warning(msg) raise TypeError(msg) + # The Source Phone # and/or short-code + result = is_phone_no(source, min_len=5) + if not result: + msg = 'The Account (From) Phone # or Short-code specified ' \ + '({}) is invalid.'.format(source) + self.logger.warning(msg) + raise TypeError(msg) + # Tidy source - self.source = re.sub(r'[^\d]+', '', self.source) + self.source = result['full'] if len(self.source) < 11 or len(self.source) > 14: # A short code is a special 5 or 6 digit telephone number @@ -233,37 +228,18 @@ class NotifySinch(NotifyBase): # 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 - # if it's less than 10, then we can assume it's - # a poorly specified phone no and spit a warning - 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('+{}'.format(result)) + for target in parse_phone_no(targets): + # Parse each phone number we found + result = is_phone_no(target) + if not result: + self.logger.warning( + 'Dropped invalid phone # ' + '({}) specified.'.format(target), + ) continue - self.logger.warning( - 'Dropped invalid phone # ' - '({}) specified.'.format(target), - ) - - if not self.targets: - if len(self.source) in (5, 6): - # raise a warning since we're a short-code. We need - # a number to message - msg = 'There are no valid Sinch targets to notify.' - self.logger.warning(msg) - raise TypeError(msg) + # store valid phone number + self.targets.append('+{}'.format(result['full'])) return @@ -272,6 +248,14 @@ class NotifySinch(NotifyBase): Perform Sinch Notification """ + if not self.targets: + if len(self.source) in (5, 6): + # Generate a warning since we're a short-code. We need + # a number to message at minimum + self.logger.warning( + 'There are no valid Sinch targets to notify.') + return False + # error tracking (used for function return) has_error = False @@ -459,6 +443,7 @@ class NotifySinch(NotifyBase): if 'from' in results['qsd'] and len(results['qsd']['from']): results['source'] = \ NotifySinch.unquote(results['qsd']['from']) + if 'source' in results['qsd'] and len(results['qsd']['source']): results['source'] = \ NotifySinch.unquote(results['qsd']['source']) @@ -472,6 +457,6 @@ class NotifySinch(NotifyBase): # The 'to' makes it easier to use yaml configuration if 'to' in results['qsd'] and len(results['qsd']['to']): results['targets'] += \ - NotifySinch.parse_list(results['qsd']['to']) + NotifySinch.parse_phone_no(results['qsd']['to']) return results diff --git a/libs/apprise/plugins/NotifySlack.py b/libs/apprise/plugins/NotifySlack.py index 3e024a64c..ff7907a35 100644 --- a/libs/apprise/plugins/NotifySlack.py +++ b/libs/apprise/plugins/NotifySlack.py @@ -43,7 +43,7 @@ # to add a 'Bot User'. Give it a name and choose 'Add Bot User'. # 4. Now you can choose 'Install App' to which you can choose 'Install App # to Workspace'. -# 5. You will need to authorize the app which you get promopted to do. +# 5. You will need to authorize the app which you get prompted to do. # 6. Finally you'll get some important information providing you your # 'OAuth Access Token' and 'Bot User OAuth Access Token' such as: # slack://{Oauth Access Token} @@ -53,6 +53,21 @@ # ... or: # slack://xoxb-1234-1234-4ddbc191d40ee098cbaae6f3523ada2d # +# You must at least give your bot the following access for it to +# be useful: +# - chat:write - MUST be set otherwise you can not post into +# a channel +# - users:read.email - Required if you want to be able to lookup +# users by their email address. +# +# The easiest way to bring a bot into a channel (so that it can send +# a message to it is to invite it. At this time Apprise does not support +# an auto-join functionality. To do this: +# - In the 'Details' section of your channel +# - Click on the 'More' [...] (elipse icon) +# - Click 'Add apps' +# - You will be able to select the Bot App you previously created +# - Your bot will join your channel. import re import requests @@ -64,6 +79,7 @@ from .NotifyBase import NotifyBase from ..common import NotifyImageSize from ..common import NotifyType from ..common import NotifyFormat +from ..utils import is_email from ..utils import parse_bool from ..utils import parse_list from ..utils import validate_regex @@ -202,6 +218,11 @@ class NotifySlack(NotifyBase): 'prefix': '+', 'map_to': 'targets', }, + 'target_email': { + 'name': _('Target Email'), + 'type': 'string', + 'map_to': 'targets', + }, 'target_user': { 'name': _('Target User'), 'type': 'string', @@ -234,14 +255,26 @@ class NotifySlack(NotifyBase): 'default': True, 'map_to': 'include_footer', }, + # Use Payload in Blocks (vs legacy way): + # See: https://api.slack.com/reference/messaging/payload + 'blocks': { + 'name': _('Use Blocks'), + 'type': 'bool', + 'default': False, + 'map_to': 'use_blocks', + }, 'to': { 'alias_of': 'targets', }, + 'token': { + 'name': _('Token'), + 'alias_of': ('access_token', 'token_a', 'token_b', 'token_c'), + }, }) def __init__(self, access_token=None, token_a=None, token_b=None, token_c=None, targets=None, include_image=True, - include_footer=True, **kwargs): + include_footer=True, use_blocks=None, **kwargs): """ Initialize Slack Object """ @@ -287,6 +320,16 @@ class NotifySlack(NotifyBase): self.logger.warning( 'No user was specified; using "%s".' % self.app_id) + # Look the users up by their email address and map them back to their + # id here for future queries (if needed). This allows people to + # specify a full email as a recipient via slack + self._lookup_users = {} + + self.use_blocks = parse_bool( + use_blocks, self.template_args['blocks']['default']) \ + if use_blocks is not None \ + else self.template_args['blocks']['default'] + # Build list of channels self.channels = parse_list(targets) if len(self.channels) == 0: @@ -330,43 +373,109 @@ class NotifySlack(NotifyBase): # error tracking (used for function return) has_error = False - # Perform Formatting - title = self._re_formatting_rules.sub( # pragma: no branch - lambda x: self._re_formatting_map[x.group()], title, - ) - body = self._re_formatting_rules.sub( # pragma: no branch - lambda x: self._re_formatting_map[x.group()], body, - ) - + # # Prepare JSON Object (applicable to both WEBHOOK and BOT mode) - payload = { - 'username': self.user if self.user else self.app_id, - # Use Markdown language - 'mrkdwn': (self.notify_format == NotifyFormat.MARKDOWN), - 'attachments': [{ - 'title': title, - 'text': body, - 'color': self.color(notify_type), - # Time - 'ts': time(), - }], - } + # + if self.use_blocks: + # Our slack format + _slack_format = 'mrkdwn' \ + if self.notify_format == NotifyFormat.MARKDOWN \ + else 'plain_text' + + payload = { + 'username': self.user if self.user else self.app_id, + 'attachments': [{ + 'blocks': [{ + 'type': 'section', + 'text': { + 'type': _slack_format, + 'text': body + } + }], + 'color': self.color(notify_type), + }] + } + + # Slack only accepts non-empty header sections + if title: + payload['attachments'][0]['blocks'].insert(0, { + 'type': 'header', + 'text': { + 'type': 'plain_text', + 'text': title, + 'emoji': True + } + }) - # Prepare our URL (depends on mode) - if self.mode is SlackMode.WEBHOOK: - url = '{}/{}/{}/{}'.format( - self.webhook_url, - self.token_a, - self.token_b, - self.token_c, + # Include the footer only if specified to do so + if self.include_footer: + + # Acquire our to-be footer icon if configured to do so + image_url = None if not self.include_image \ + else self.image_url(notify_type) + + # Prepare our footer based on the block structure + _footer = { + 'type': 'context', + 'elements': [{ + 'type': _slack_format, + 'text': self.app_id + }] + } + + if image_url: + payload['icon_url'] = image_url + + _footer['elements'].insert(0, { + 'type': 'image', + 'image_url': image_url, + 'alt_text': notify_type + }) + + payload['attachments'][0]['blocks'].append(_footer) + + else: + # + # Legacy API Formatting + # + if self.notify_format == NotifyFormat.MARKDOWN: + body = self._re_formatting_rules.sub( # pragma: no branch + lambda x: self._re_formatting_map[x.group()], body, + ) + + # Perform Formatting on title here; this is not needed for block + # mode above + title = self._re_formatting_rules.sub( # pragma: no branch + lambda x: self._re_formatting_map[x.group()], title, ) - else: # SlackMode.BOT - url = self.api_url.format('chat.postMessage') + # Prepare JSON Object (applicable to both WEBHOOK and BOT mode) + payload = { + 'username': self.user if self.user else self.app_id, + # Use Markdown language + 'mrkdwn': (self.notify_format == NotifyFormat.MARKDOWN), + 'attachments': [{ + 'title': title, + 'text': body, + 'color': self.color(notify_type), + # Time + 'ts': time(), + }], + } + # Acquire our to-be footer icon if configured to do so + image_url = None if not self.include_image \ + else self.image_url(notify_type) + + if image_url: + payload['icon_url'] = image_url - if self.include_footer: # Include the footer only if specified to do so - payload['attachments'][0]['footer'] = self.app_id + if self.include_footer: + if image_url: + payload['attachments'][0]['footer_icon'] = image_url + + # Include the footer only if specified to do so + payload['attachments'][0]['footer'] = self.app_id if attach and self.mode is SlackMode.WEBHOOK: # Be friendly; let the user know why they can't send their @@ -374,6 +483,18 @@ class NotifySlack(NotifyBase): self.logger.warning( 'Slack Webhooks do not support attachments.') + # Prepare our Slack URL (depends on mode) + if self.mode is SlackMode.WEBHOOK: + url = '{}/{}/{}/{}'.format( + self.webhook_url, + self.token_a, + self.token_b, + self.token_c, + ) + + else: # SlackMode.BOT + url = self.api_url.format('chat.postMessage') + # Create a copy of the channel list channels = list(self.channels) @@ -382,45 +503,47 @@ class NotifySlack(NotifyBase): channel = channels.pop(0) if channel is not None: - _channel = validate_regex( - channel, r'[+#@]?(?P<value>[A-Z0-9_]{1,32})') - - if not _channel: + channel = validate_regex(channel, r'[+#@]?[A-Z0-9_]{1,32}') + if not channel: # Channel over-ride was specified self.logger.warning( "The specified target {} is invalid;" - "skipping.".format(_channel)) + "skipping.".format(channel)) # Mark our failure has_error = True continue - if len(_channel) > 1 and _channel[0] == '+': + if channel[0] == '+': # Treat as encoded id if prefixed with a + - payload['channel'] = _channel[1:] + payload['channel'] = channel[1:] - elif len(_channel) > 1 and _channel[0] == '@': + elif channel[0] == '@': # Treat @ value 'as is' - payload['channel'] = _channel + payload['channel'] = channel else: - # Prefix with channel hash tag - payload['channel'] = '#{}'.format(_channel) + # We'll perform a user lookup if we detect an email + email = is_email(channel) + if email: + payload['channel'] = \ + self.lookup_userid(email['full_email']) + + if not payload['channel']: + # Move along; any notifications/logging would have + # come from lookup_userid() + has_error = True + continue + else: + # Prefix with channel hash tag (if not already) + payload['channel'] = \ + channel if channel[0] == '#' \ + else '#{}'.format(channel) # Store the valid and massaged payload that is recognizable by # slack. This list is used for sending attachments later. attach_channel_list.append(payload['channel']) - # Acquire our to-be footer icon if configured to do so - image_url = None if not self.include_image \ - else self.image_url(notify_type) - - if image_url: - payload['icon_url'] = image_url - - if self.include_footer: - payload['attachments'][0]['footer_icon'] = image_url - response = self._send(url, payload) if not response: # Handle any error @@ -465,6 +588,162 @@ class NotifySlack(NotifyBase): return not has_error + def lookup_userid(self, email): + """ + Takes an email address and attempts to resolve/acquire it's user + id for notification purposes. + """ + if email in self._lookup_users: + # We're done as entry has already been retrieved + return self._lookup_users[email] + + if self.mode is not SlackMode.BOT: + # You can not look up + self.logger.warning( + 'Emails can not be resolved to Slack User IDs unless you ' + 'have a bot configured.') + return None + + lookup_url = self.api_url.format('users.lookupByEmail') + headers = { + 'User-Agent': self.app_id, + 'Content-Type': 'application/x-www-form-urlencoded', + 'Authorization': 'Bearer {}'.format(self.access_token), + } + + # we pass in our email address as the argument + params = { + 'email': email, + } + + self.logger.debug('Slack User Lookup POST URL: %s (cert_verify=%r)' % ( + lookup_url, self.verify_certificate, + )) + self.logger.debug('Slack User Lookup Parameters: %s' % str(params)) + + # Initialize our HTTP JSON response + response = {'ok': False} + + # Initialize our detected user id (also the response to this function) + user_id = None + + # Always call throttle before any remote server i/o is made + self.throttle() + try: + r = requests.get( + lookup_url, + headers=headers, + params=params, + verify=self.verify_certificate, + timeout=self.request_timeout, + ) + + # Attachment posts return a JSON string + try: + response = loads(r.content) + + except (AttributeError, TypeError, ValueError): + # ValueError = r.content is Unparsable + # TypeError = r.content is None + # AttributeError = r is None + pass + + # We can get a 200 response, but still fail. A failure message + # might look like this (missing bot permissions): + # { + # 'ok': False, + # 'error': 'missing_scope', + # 'needed': 'users:read.email', + # 'provided': 'calls:write,chat:write' + # } + + if r.status_code != requests.codes.ok \ + or not (response and response.get('ok', False)): + + # We had a problem + status_str = \ + NotifySlack.http_response_code_lookup( + r.status_code, SLACK_HTTP_ERROR_MAP) + + self.logger.warning( + 'Failed to send Slack User Lookup:' + '{}{}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 + + # If we reach here, then we were successful in looking up + # the user. A response generally looks like this: + # { + # 'ok': True, + # 'user': { + # 'id': 'J1ZQB9T9Y', + # 'team_id': 'K1WR6TML2', + # 'name': 'l2g', + # 'deleted': False, + # 'color': '9f69e7', + # 'real_name': 'Chris C', + # 'tz': 'America/New_York', + # 'tz_label': 'Eastern Standard Time', + # 'tz_offset': -18000, + # 'profile': { + # 'title': '', + # 'phone': '', + # 'skype': '', + # 'real_name': 'Chris C', + # 'real_name_normalized': + # 'Chris C', + # 'display_name': 'l2g', + # 'display_name_normalized': 'l2g', + # 'fields': None, + # 'status_text': '', + # 'status_emoji': '', + # 'status_expiration': 0, + # 'avatar_hash': 'g785e9c0ddf6', + # 'email': '[email protected]', + # 'first_name': 'Chris', + # 'last_name': 'C', + # 'image_24': 'https://secure.gravatar.com/...', + # 'image_32': 'https://secure.gravatar.com/...', + # 'image_48': 'https://secure.gravatar.com/...', + # 'image_72': 'https://secure.gravatar.com/...', + # 'image_192': 'https://secure.gravatar.com/...', + # 'image_512': 'https://secure.gravatar.com/...', + # 'status_text_canonical': '', + # 'team': 'K1WR6TML2' + # }, + # 'is_admin': True, + # 'is_owner': True, + # 'is_primary_owner': True, + # 'is_restricted': False, + # 'is_ultra_restricted': False, + # 'is_bot': False, + # 'is_app_user': False, + # 'updated': 1603904274 + # } + # } + # We're only interested in the id + user_id = response['user']['id'] + + # Cache it for future + self._lookup_users[email] = user_id + self.logger.info( + 'Email %s resolves to the Slack User ID: %s.', email, user_id) + + except requests.RequestException as e: + self.logger.warning( + 'A Connection error occurred looking up Slack User.', + ) + self.logger.debug('Socket Exception: %s' % str(e)) + # Return; we're done + return None + + return user_id + def _send(self, url, payload, attach=None, **kwargs): """ Wrapper to the requests (post) object @@ -477,6 +756,7 @@ class NotifySlack(NotifyBase): headers = { 'User-Agent': self.app_id, + 'Accept': 'application/json', } if not attach: @@ -486,7 +766,7 @@ class NotifySlack(NotifyBase): headers['Authorization'] = 'Bearer {}'.format(self.access_token) # Our response object - response = None + response = {'ok': False} # Always call throttle before any remote server i/o is made self.throttle() @@ -508,7 +788,28 @@ class NotifySlack(NotifyBase): timeout=self.request_timeout, ) - if r.status_code != requests.codes.ok: + # Posts return a JSON string + try: + response = loads(r.content) + + except (AttributeError, TypeError, ValueError): + # ValueError = r.content is Unparsable + # TypeError = r.content is None + # AttributeError = r is None + pass + + # Another response type is: + # { + # 'ok': False, + # 'error': 'not_in_channel', + # } + # + # The text 'ok' is returned if this is a Webhook request + # So the below captures that as well. + status_okay = (response and response.get('ok', False)) \ + if self.mode is SlackMode.BOT else r.text == 'ok' + + if r.status_code != requests.codes.ok or not status_okay: # We had a problem status_str = \ NotifySlack.http_response_code_lookup( @@ -526,30 +827,6 @@ class NotifySlack(NotifyBase): 'Response Details:\r\n{}'.format(r.content)) return False - elif attach: - # Attachment posts return a JSON string - try: - response = loads(r.content) - - except (AttributeError, TypeError, ValueError): - # ValueError = r.content is Unparsable - # TypeError = r.content is None - # AttributeError = r is None - pass - - if not (response and response.get('ok', True)): - # Bare minimum requirements not met - self.logger.warning( - 'Failed to send {}to Slack: error={}.'.format( - attach.name if attach else '', - r.status_code)) - - self.logger.debug( - 'Response Details:\r\n{}'.format(r.content)) - return False - else: - response = r.content - # Message Post Response looks like this: # { # "attachments": [ @@ -653,19 +930,20 @@ class NotifySlack(NotifyBase): params = { 'image': 'yes' if self.include_image else 'no', 'footer': 'yes' if self.include_footer else 'no', + 'blocks': 'yes' if self.use_blocks else 'no', } # Extend our parameters params.update(self.url_parameters(privacy=privacy, *args, **kwargs)) - if self.mode == SlackMode.WEBHOOK: - # Determine if there is a botname present - botname = '' - if self.user: - botname = '{botname}@'.format( - botname=NotifySlack.quote(self.user, safe=''), - ) + # Determine if there is a botname present + botname = '' + if self.user: + botname = '{botname}@'.format( + botname=NotifySlack.quote(self.user, safe=''), + ) + if self.mode == SlackMode.WEBHOOK: return '{schema}://{botname}{token_a}/{token_b}/{token_c}/'\ '{targets}/?{params}'.format( schema=self.secure_protocol, @@ -679,9 +957,10 @@ class NotifySlack(NotifyBase): params=NotifySlack.urlencode(params), ) # else -> self.mode == SlackMode.BOT: - return '{schema}://{access_token}/{targets}/'\ + return '{schema}://{botname}{access_token}/{targets}/'\ '?{params}'.format( schema=self.secure_protocol, + botname=botname, access_token=self.pprint(self.access_token, privacy, safe=''), targets='/'.join( [NotifySlack.quote(x, safe='') for x in self.channels]), @@ -714,24 +993,35 @@ class NotifySlack(NotifyBase): else: # We're dealing with a webhook results['token_a'] = token + results['token_b'] = entries.pop(0) if entries else None + results['token_c'] = entries.pop(0) if entries else None - # Now fetch the remaining tokens - try: - results['token_b'] = entries.pop(0) - - except IndexError: - # We're done - results['token_b'] = None + # assign remaining entries to the channels we wish to notify + results['targets'] = entries - try: - results['token_c'] = entries.pop(0) + # Support the token flag where you can set it to the bot token + # or the webhook token (with slash delimiters) + if 'token' in results['qsd'] and len(results['qsd']['token']): + # Break our entries up into a list; we can ue the Channel + # list delimiter above since it doesn't contain any characters + # we don't otherwise accept anyway in our token + entries = [x for x in filter( + bool, CHANNEL_LIST_DELIM.split( + NotifySlack.unquote(results['qsd']['token'])))] - except IndexError: - # We're done + # check to see if we're dealing with a bot/user token + if entries and entries[0].startswith('xo'): + # We're dealing with a bot + results['access_token'] = entries[0] + results['token_a'] = None + results['token_b'] = None results['token_c'] = None - # assign remaining entries to the channels we wish to notify - results['targets'] = entries + else: # Webhook + results['access_token'] = None + results['token_a'] = entries.pop(0) if entries else None + results['token_b'] = entries.pop(0) if entries else None + results['token_c'] = entries.pop(0) if entries else None # Support the 'to' variable so that we can support rooms this way too # The 'to' makes it easier to use yaml configuration @@ -744,6 +1034,10 @@ class NotifySlack(NotifyBase): results['include_image'] = \ parse_bool(results['qsd'].get('image', True)) + # Get Payload structure (use blocks?) + if 'blocks' in results['qsd'] and len(results['qsd']['blocks']): + results['use_blocks'] = parse_bool(results['qsd']['blocks']) + # Get Footer Flag results['include_footer'] = \ parse_bool(results['qsd'].get('footer', True)) diff --git a/libs/apprise/plugins/NotifySparkPost.py b/libs/apprise/plugins/NotifySparkPost.py new file mode 100644 index 000000000..78ed9f084 --- /dev/null +++ b/libs/apprise/plugins/NotifySparkPost.py @@ -0,0 +1,784 @@ +# -*- coding: utf-8 -*- +# +# Copyright (C) 2020 Chris Caron <[email protected]> +# All rights reserved. +# +# This code is licensed under the MIT License. +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files(the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and / or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions : +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. + +# Signup @ https://www.sparkpost.com +# +# Ensure you've added a Senders Domain and have generated yourself an +# API Key at: +# https://app.sparkpost.com/dashboard + +# Note: For SMTP Access, your API key must have at least been granted the +# 'Send via SMTP' privileges. + +# From here you can click on the domain you're interested in. You can acquire +# the API Key from here which will look something like: +# 1e1d479fcf1a87527e9411e083c700689fa1acdc +# +# Knowing this, you can buid your sparkpost url as follows: +# sparkpost://{user}@{domain}/{apikey} +# sparkpost://{user}@{domain}/{apikey}/{email} +# +# You can email as many addresses as you want as: +# sparkpost://{user}@{domain}/{apikey}/{email1}/{email2}/{emailN} +# +# The {user}@{domain} effectively assembles the 'from' email address +# the email will be transmitted from. If no email address is specified +# then it will also become the 'to' address as well. +# +# The {domain} must cross reference a domain you've set up with Spark Post +# +# API Documentation: https://developers.sparkpost.com/api/ +# Specifically: https://developers.sparkpost.com/api/transmissions/ +import requests +import base64 +from json import loads +from json import dumps +from .NotifyBase import NotifyBase +from ..common import NotifyType +from ..common import NotifyFormat +from ..utils import is_email +from email.utils import formataddr +from ..utils import validate_regex +from ..utils import parse_emails +from ..utils import parse_bool +from ..AppriseLocale import gettext_lazy as _ + +# Provide some known codes SparkPost uses and what they translate to: +# Based on https://www.sparkpost.com/docs/tech-resources/extended-error-codes/ +SPARKPOST_HTTP_ERROR_MAP = { + 400: 'A bad request was made to the server', + 401: 'Invalid User ID and/or Unauthorized User', + 403: 'Permission Denied; the provided API Key was not valid', + 404: 'There is a problem with the server query URI.', + 405: 'Invalid HTTP method', + 420: 'Sending limit reached.', + 422: 'Invalid data/format/type/length', + 429: 'To many requests per sec; rate limit', +} + + +# Priorities +class SparkPostRegion(object): + US = 'us' + EU = 'eu' + + +# SparkPost APIs +SPARKPOST_API_LOOKUP = { + SparkPostRegion.US: 'https://api.sparkpost.com/api/v1', + SparkPostRegion.EU: 'https://api.eu.sparkpost.com/api/v1', +} + +# A List of our regions we can use for verification +SPARKPOST_REGIONS = ( + SparkPostRegion.US, + SparkPostRegion.EU, +) + + +class NotifySparkPost(NotifyBase): + """ + A wrapper for SparkPost Notifications + """ + + # The default descriptive name associated with the Notification + service_name = 'SparkPost' + + # The services URL + service_url = 'https://sparkpost.com/' + + # All notification requests are secure + secure_protocol = 'sparkpost' + + # SparkPost advertises they allow 300 requests per minute. + # 60/300 = 0.2 + request_rate_per_sec = 0.20 + + # Words straight from their website: + # https://developers.sparkpost.com/api/#header-rate-limiting + # These limits are dynamic, but as a general rule, wait 1 to 5 seconds + # after receiving a 429 response before requesting again. + + # As a simple work around, this is what we will do... Wait X seconds + # (defined below) before trying again when we get a 429 error + sparkpost_retry_wait_sec = 5 + + # The maximum number of times we'll retry to send our message when we've + # reached a throttling situatin before giving up + sparkpost_retry_attempts = 3 + + # The maximum amount of emails that can reside within a single + # batch transfer based on: + # https://www.sparkpost.com/docs/tech-resources/\ + # smtp-rest-api-performance/#sending-via-the-transmission-rest-api + default_batch_size = 2000 + + # A URL that takes you to the setup/help of the specific protocol + setup_url = 'https://github.com/caronc/apprise/wiki/Notify_sparkpost' + + # Default Notify Format + notify_format = NotifyFormat.HTML + + # The default region to use if one isn't otherwise specified + sparkpost_default_region = SparkPostRegion.US + + # Define object templates + templates = ( + '{schema}://{user}@{host}:{apikey}/', + '{schema}://{user}@{host}:{apikey}/{targets}', + ) + + # Define our template tokens + template_tokens = dict(NotifyBase.template_tokens, **{ + 'user': { + 'name': _('User Name'), + 'type': 'string', + 'required': True, + }, + 'host': { + 'name': _('Domain'), + 'type': 'string', + 'required': True, + }, + 'apikey': { + 'name': _('API Key'), + 'type': 'string', + 'private': True, + 'required': True, + }, + 'targets': { + 'name': _('Target Emails'), + 'type': 'list:string', + }, + }) + + # Define our template arguments + template_args = dict(NotifyBase.template_args, **{ + 'name': { + 'name': _('From Name'), + 'type': 'string', + 'map_to': 'from_name', + }, + 'region': { + 'name': _('Region Name'), + 'type': 'choice:string', + 'values': SPARKPOST_REGIONS, + 'default': SparkPostRegion.US, + 'map_to': 'region_name', + }, + 'to': { + 'alias_of': 'targets', + }, + 'cc': { + 'name': _('Carbon Copy'), + 'type': 'list:string', + }, + 'bcc': { + 'name': _('Blind Carbon Copy'), + 'type': 'list:string', + }, + 'batch': { + 'name': _('Batch Mode'), + 'type': 'bool', + 'default': False, + }, + }) + + # Define any kwargs we're using + template_kwargs = { + 'headers': { + 'name': _('Email Header'), + 'prefix': '+', + }, + 'tokens': { + 'name': _('Template Tokens'), + 'prefix': ':', + }, + } + + def __init__(self, apikey, targets, cc=None, bcc=None, from_name=None, + region_name=None, headers=None, tokens=None, batch=False, + **kwargs): + """ + Initialize SparkPost Object + """ + super(NotifySparkPost, self).__init__(**kwargs) + + # API Key (associated with project) + self.apikey = validate_regex(apikey) + if not self.apikey: + msg = 'An invalid SparkPost API Key ' \ + '({}) was specified.'.format(apikey) + self.logger.warning(msg) + raise TypeError(msg) + + # Validate our username + if not self.user: + msg = 'No SparkPost username was specified.' + self.logger.warning(msg) + raise TypeError(msg) + + # Acquire Email 'To' + self.targets = list() + + # Acquire Carbon Copies + self.cc = set() + + # Acquire Blind Carbon Copies + self.bcc = set() + + # For tracking our email -> name lookups + self.names = {} + + # Store our region + try: + self.region_name = self.sparkpost_default_region \ + if region_name is None else region_name.lower() + + if self.region_name not in SPARKPOST_REGIONS: + # allow the outer except to handle this common response + raise + except: + # Invalid region specified + msg = 'The SparkPost region specified ({}) is invalid.' \ + .format(region_name) + self.logger.warning(msg) + raise TypeError(msg) + + # Get our From username (if specified) + self.from_name = from_name + + # Get our from email address + self.from_addr = '{user}@{host}'.format(user=self.user, host=self.host) + + if not is_email(self.from_addr): + # Parse Source domain based on from_addr + msg = 'Invalid ~From~ email format: {}'.format(self.from_addr) + self.logger.warning(msg) + raise TypeError(msg) + + self.headers = {} + if headers: + # Store our extra headers + self.headers.update(headers) + + self.tokens = {} + if tokens: + # Store our template tokens + self.tokens.update(tokens) + + # Prepare Batch Mode Flag + self.batch = batch + + if targets: + # Validate recipients (to:) and drop bad ones: + for recipient in parse_emails(targets): + result = is_email(recipient) + if result: + self.targets.append( + (result['name'] if result['name'] else False, + result['full_email'])) + continue + + self.logger.warning( + 'Dropped invalid To email ' + '({}) specified.'.format(recipient), + ) + + else: + # If our target email list is empty we want to add ourselves to it + self.targets.append( + (self.from_name if self.from_name else False, self.from_addr)) + + # Validate recipients (cc:) and drop bad ones: + for recipient in parse_emails(cc): + email = is_email(recipient) + if email: + self.cc.add(email['full_email']) + + # Index our name (if one exists) + self.names[email['full_email']] = \ + email['name'] if email['name'] else False + continue + + self.logger.warning( + 'Dropped invalid Carbon Copy email ' + '({}) specified.'.format(recipient), + ) + + # Validate recipients (bcc:) and drop bad ones: + for recipient in parse_emails(bcc): + email = is_email(recipient) + if email: + self.bcc.add(email['full_email']) + + # Index our name (if one exists) + self.names[email['full_email']] = \ + email['name'] if email['name'] else False + continue + + self.logger.warning( + 'Dropped invalid Blind Carbon Copy email ' + '({}) specified.'.format(recipient), + ) + + def __post(self, payload, retry): + """ + Performs the actual post and returns the response + + """ + # Prepare our headers + headers = { + 'User-Agent': self.app_id, + 'Accept': 'application/json', + 'Content-Type': 'application/json', + 'Authorization': self.apikey, + } + + # Prepare our URL as it's based on our hostname + url = '{}/transmissions/'.format( + SPARKPOST_API_LOOKUP[self.region_name]) + + # Some Debug Logging + self.logger.debug('SparkPost POST URL: {} (cert_verify={})'.format( + url, self.verify_certificate)) + + if 'attachments' in payload['content']: + # Since we print our payload; attachments make it a bit too noisy + # we just strip out the data block to accomodate it + log_payload = \ + {k: v for k, v in payload.items() if k != "content"} + log_payload['content'] = \ + {k: v for k, v in payload['content'].items() + if k != "attachments"} + log_payload['content']['attachments'] = \ + [{k: v for k, v in x.items() if k != "data"} + for x in payload['content']['attachments']] + else: + # No tidying is needed + log_payload = payload + + self.logger.debug('SparkPost Payload: {}' .format(log_payload)) + + wait = None + + # For logging output of success and errors; we get a head count + # of our outbound details: + verbose_dest = ', '.join( + [x['address']['email'] for x in payload['recipients']]) \ + if len(payload['recipients']) <= 3 \ + else '{} recipients'.format(len(payload['recipients'])) + + # Initialize our response object + json_response = {} + + # Set ourselves a status code + status_code = -1 + + while 1: # pragma: no branch + + # Always call throttle before any remote server i/o is made + self.throttle(wait=wait) + try: + r = requests.post( + url, + data=dumps(payload), + headers=headers, + verify=self.verify_certificate, + timeout=self.request_timeout, + ) + + # A Good response (200) looks like this: + # "results": { + # "total_rejected_recipients": 0, + # "total_accepted_recipients": 1, + # "id": "11668787484950529" + # } + # } + # + # A Bad response looks like this: + # { + # "errors": [ + # { + # "description": + # "Unconfigured or unverified sending domain.", + # "code": "7001", + # "message": "Invalid domain" + # } + # ] + # } + # + try: + # Update our status response if we can + json_response = loads(r.content) + + 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 + + status_code = r.status_code + + payload['recipients'] = list() + if status_code == requests.codes.ok: + self.logger.info( + 'Sent SparkPost notification to {}.'.format( + verbose_dest)) + return status_code, json_response + + # We had a problem if we get here + status_str = \ + NotifyBase.http_response_code_lookup( + status_code, SPARKPOST_API_LOOKUP) + + self.logger.warning( + 'Failed to send SparkPost notification to {}: ' + '{}{}error={}.'.format( + verbose_dest, + status_str, + ', ' if status_str else '', + status_code)) + + self.logger.debug( + 'Response Details:\r\n{}'.format(r.content)) + + if status_code == requests.codes.too_many_requests and retry: + retry = retry - 1 + if retry > 0: + wait = self.sparkpost_retry_wait_sec + continue + + except requests.RequestException as e: + self.logger.warning( + 'A Connection error occurred sending SparkPost ' + 'notification') + self.logger.debug('Socket Exception: %s' % str(e)) + + # Anything else and we're done + return status_code, json_response + + # Our code will never reach here (outside of infinite while loop above) + + def send(self, body, title='', notify_type=NotifyType.INFO, attach=None, + **kwargs): + """ + Perform SparkPost Notification + """ + + if not self.targets: + # There is no one to email; we're done + self.logger.warning( + 'There are no Email recipients to notify') + return False + + # Initialize our has_error flag + has_error = False + + # Send in batches if identified to do so + batch_size = 1 if not self.batch else self.default_batch_size + + try: + reply_to = formataddr((self.from_name if self.from_name else False, + self.from_addr), charset='utf-8') + except TypeError: + # Python v2.x Support (no charset keyword) + # Format our cc addresses to support the Name field + reply_to = formataddr((self.from_name if self.from_name else False, + self.from_addr)) + payload = { + "options": { + # When set to True, an image is included with the email which + # is used to detect if the user looked at the image or not. + 'open_tracking': False, + + # Track if links were clicked that were found within email + 'click_tracking': False, + }, + "content": { + "from": { + "name": self.from_name + if self.from_name else self.app_desc, + "email": self.from_addr, + }, + + # SparkPost does not allow empty subject lines or lines that + # only contain whitespace; Since Apprise allows an empty title + # parameter we swap empty title entries with the period + "subject": title if title.strip() else '.', + "reply_to": reply_to, + } + } + + if self.notify_format == NotifyFormat.HTML: + payload['content']['html'] = body + + else: + payload['content']['text'] = body + + if attach: + # Prepare ourselves an attachment object + payload['content']['attachments'] = [] + + for attachment in attach: + # 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 + + self.logger.debug( + 'Preparing SparkPost attachment {}'.format( + attachment.url(privacy=True))) + + try: + with open(attachment.path, 'rb') as fp: + # Prepare API Upload Payload + payload['content']['attachments'].append({ + 'name': attachment.name, + 'type': attachment.mimetype, + 'data': base64.b64encode(fp.read()).decode("ascii") + }) + + 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 + + # Take a copy of our token dictionary + tokens = self.tokens.copy() + + # Apply some defaults template values + tokens['app_body'] = body + tokens['app_title'] = title + tokens['app_type'] = notify_type + tokens['app_id'] = self.app_id + tokens['app_desc'] = self.app_desc + tokens['app_color'] = self.color(notify_type) + tokens['app_url'] = self.app_url + + # Store our tokens if they're identified + payload['substitution_data'] = self.tokens + + # Create a copy of the targets list + emails = list(self.targets) + + for index in range(0, len(emails), batch_size): + # Generate our email listing + payload['recipients'] = list() + + # Initialize our cc list + cc = (self.cc - self.bcc) + + # Initialize our bcc list + bcc = set(self.bcc) + + # Initialize our headers + headers = self.headers.copy() + + for addr in self.targets[index:index + batch_size]: + entry = { + 'address': { + 'email': addr[1], + } + } + + # Strip target out of cc list if in To + cc = (cc - set([addr[1]])) + + # Strip target out of bcc list if in To + bcc = (bcc - set([addr[1]])) + + if addr[0]: + entry['address']['name'] = addr[0] + + # Add our recipient to our list + payload['recipients'].append(entry) + + if cc: + # Handle our cc List + for addr in cc: + entry = { + 'address': { + 'email': addr, + 'header_to': + # Take the first email in the To + self.targets[index:index + batch_size][0][1], + }, + } + + if self.names.get(addr): + entry['address']['name'] = self.names[addr] + + # Add our recipient to our list + payload['recipients'].append(entry) + + headers['CC'] = ','.join(cc) + + # Handle our bcc + for addr in bcc: + # Add our recipient to our list + payload['recipients'].append({ + 'address': { + 'email': addr, + 'header_to': + # Take the first email in the To + self.targets[index:index + batch_size][0][1], + }, + }) + + if headers: + payload['content']['headers'] = headers + + # Send our message + status_code, response = \ + self.__post(payload, self.sparkpost_retry_attempts) + + # Failed + if status_code != requests.codes.ok: + 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 = { + 'region': self.region_name, + 'batch': 'yes' if self.batch else 'no', + } + + # Append our headers into our parameters + params.update({'+{}'.format(k): v for k, v in self.headers.items()}) + + # Append our template tokens into our parameters + params.update({':{}'.format(k): v for k, v in self.tokens.items()}) + + # Extend our parameters + params.update(self.url_parameters(privacy=privacy, *args, **kwargs)) + + if self.from_name is not None: + # from_name specified; pass it back on the url + params['name'] = self.from_name + + if self.cc: + # Handle our Carbon Copy Addresses + params['cc'] = ','.join( + ['{}{}'.format( + '' if not e not in self.names + else '{}:'.format(self.names[e]), e) for e in self.cc]) + + if self.bcc: + # Handle our Blind Carbon Copy Addresses + params['bcc'] = ','.join(self.bcc) + + # a simple boolean check as to whether we display our target emails + # or not + has_targets = \ + not (len(self.targets) == 1 + and self.targets[0][1] == self.from_addr) + + return '{schema}://{user}@{host}/{apikey}/{targets}/?{params}'.format( + schema=self.secure_protocol, + host=self.host, + user=NotifySparkPost.quote(self.user, safe=''), + apikey=self.pprint(self.apikey, privacy, safe=''), + targets='' if not has_targets else '/'.join( + [NotifySparkPost.quote('{}{}'.format( + '' if not e[0] else '{}:'.format(e[0]), e[1]), + safe='') for e in self.targets]), + params=NotifySparkPost.urlencode(params)) + + @staticmethod + def parse_url(url): + """ + Parses the URL and returns enough arguments that can allow + us to re-instantiate this object. + + """ + results = NotifyBase.parse_url(url, verify_host=False) + if not results: + # We're done early as we couldn't load the results + return results + + # Get our entries; split_path() looks after unquoting content for us + # by default + results['targets'] = NotifySparkPost.split_path(results['fullpath']) + + # Our very first entry is reserved for our api key + try: + results['apikey'] = results['targets'].pop(0) + + except IndexError: + # We're done - no API Key found + results['apikey'] = None + + if 'name' in results['qsd'] and len(results['qsd']['name']): + # Extract from name to associate with from address + results['from_name'] = \ + NotifySparkPost.unquote(results['qsd']['name']) + + if 'region' in results['qsd'] and len(results['qsd']['region']): + # Extract from name to associate with from address + results['region_name'] = \ + NotifySparkPost.unquote(results['qsd']['region']) + + # Handle 'to' email address + if 'to' in results['qsd'] and len(results['qsd']['to']): + results['targets'].append(results['qsd']['to']) + + # Handle Carbon Copy Addresses + if 'cc' in results['qsd'] and len(results['qsd']['cc']): + results['cc'] = results['qsd']['cc'] + + # Handle Blind Carbon Copy Addresses + if 'bcc' in results['qsd'] and len(results['qsd']['bcc']): + results['bcc'] = results['qsd']['bcc'] + + # Add our Meta Headers that the user can provide with their outbound + # emails + results['headers'] = {NotifyBase.unquote(x): NotifyBase.unquote(y) + for x, y in results['qsd+'].items()} + + # Add our template tokens (if defined) + results['tokens'] = {NotifyBase.unquote(x): NotifyBase.unquote(y) + for x, y in results['qsd:'].items()} + + # Get Batch Mode Flag + results['batch'] = \ + parse_bool(results['qsd'].get( + 'batch', NotifySparkPost.template_args['batch']['default'])) + + return results diff --git a/libs/apprise/plugins/NotifySpontit.py b/libs/apprise/plugins/NotifySpontit.py index 91388ea18..0e8811c33 100644 --- a/libs/apprise/plugins/NotifySpontit.py +++ b/libs/apprise/plugins/NotifySpontit.py @@ -365,7 +365,8 @@ class NotifySpontit(NotifyBase): # Support MacOS subtitle option if 'subtitle' in results['qsd'] and len(results['qsd']['subtitle']): - results['subtitle'] = results['qsd']['subtitle'] + results['subtitle'] = \ + NotifySpontit.unquote(results['qsd']['subtitle']) # Support the 'to' variable so that we can support targets this way too # The 'to' makes it easier to use yaml configuration diff --git a/libs/apprise/plugins/NotifyStreamlabs.py b/libs/apprise/plugins/NotifyStreamlabs.py new file mode 100644 index 000000000..5941537df --- /dev/null +++ b/libs/apprise/plugins/NotifyStreamlabs.py @@ -0,0 +1,467 @@ +# -*- coding: utf-8 -*- +# +# Copyright (C) 2021 <[email protected]> +# All rights reserved. +# +# This code is licensed under the MIT License. +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files(the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and / or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions : +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. + +# For this to work correctly you need to register an app +# and generate an access token +# +# +# This plugin will simply work using the url of: +# streamlabs://access_token/ +# +# API Documentation on Webhooks: +# - https://dev.streamlabs.com/ +# +import requests + +from .NotifyBase import NotifyBase +from ..common import NotifyType +from ..utils import validate_regex +from ..AppriseLocale import gettext_lazy as _ + + +# calls +class StrmlabsCall(object): + ALERT = 'ALERTS' + DONATION = 'DONATIONS' + + +# A List of calls we can use for verification +STRMLABS_CALLS = ( + StrmlabsCall.ALERT, + StrmlabsCall.DONATION, +) + + +# alerts +class StrmlabsAlert(object): + FOLLOW = 'follow' + SUBSCRIPTION = 'subscription' + DONATION = 'donation' + HOST = 'host' + + +# A List of calls we can use for verification +STRMLABS_ALERTS = ( + StrmlabsAlert.FOLLOW, + StrmlabsAlert.SUBSCRIPTION, + StrmlabsAlert.DONATION, + StrmlabsAlert.HOST, +) + + +class NotifyStreamlabs(NotifyBase): + """ + A wrapper to Streamlabs Donation Notifications + + """ + # The default descriptive name associated with the Notification + service_name = 'Streamlabs' + + # The services URL + service_url = 'https://streamlabs.com/' + + # The default secure protocol + secure_protocol = 'strmlabs' + + # A URL that takes you to the setup/help of the specific protocol + setup_url = 'https://github.com/caronc/apprise/wiki/Notify_streamlabs' + + # Streamlabs Api endpoint + notify_url = 'https://streamlabs.com/api/v1.0/' + + # The maximum allowable characters allowed in the body per message + body_maxlen = 255 + + # Define object templates + templates = ( + '{schema}://{access_token}/', + ) + + # Define our template tokens + template_tokens = dict(NotifyBase.template_tokens, **{ + 'access_token': { + 'name': _('Access Token'), + 'private': True, + 'required': True, + 'type': 'string', + 'regex': (r'^[a-z0-9]{40}$', 'i') + }, + }) + + # Define our template arguments + template_args = dict(NotifyBase.template_args, **{ + 'call': { + 'name': _('Call'), + 'type': 'choice:string', + 'values': STRMLABS_CALLS, + 'default': StrmlabsCall.ALERT, + }, + 'alert_type': { + 'name': _('Alert Type'), + 'type': 'choice:string', + 'values': STRMLABS_ALERTS, + 'default': StrmlabsAlert.DONATION, + }, + 'image_href': { + 'name': _('Image Link'), + 'type': 'string', + 'default': '', + }, + 'sound_href': { + 'name': _('Sound Link'), + 'type': 'string', + 'default': '', + }, + 'duration': { + 'name': _('Duration'), + 'type': 'int', + 'default': 1000, + 'min': 0 + }, + 'special_text_color': { + 'name': _('Special Text Color'), + 'type': 'string', + 'default': '', + 'regex': (r'^[A-Z]$', 'i'), + }, + 'amount': { + 'name': _('Amount'), + 'type': 'int', + 'default': 0, + 'min': 0 + }, + 'currency': { + 'name': _('Currency'), + 'type': 'string', + 'default': 'USD', + 'regex': (r'^[A-Z]{3}$', 'i'), + }, + 'name': { + 'name': _('Name'), + 'type': 'string', + 'default': 'Anon', + 'regex': (r'^[^\s].{1,24}$', 'i') + }, + 'identifier': { + 'name': _('Identifier'), + 'type': 'string', + 'default': 'Apprise', + }, + }) + + def __init__(self, access_token, + call=StrmlabsCall.ALERT, + alert_type=StrmlabsAlert.DONATION, + image_href='', sound_href='', duration=1000, + special_text_color='', + amount=0, currency='USD', name='Anon', + identifier='Apprise', + **kwargs): + """ + Initialize Streamlabs Object + + """ + super(NotifyStreamlabs, self).__init__(**kwargs) + + # access token is generated by user + # using https://streamlabs.com/api/v1.0/token + # Tokens for Streamlabs never need to be refreshed. + self.access_token = validate_regex( + access_token, + *self.template_tokens['access_token']['regex'] + ) + if not self.access_token: + msg = 'An invalid Streamslabs access token was specified.' + self.logger.warning(msg) + raise TypeError(msg) + + # Store the call + try: + if call not in STRMLABS_CALLS: + # allow the outer except to handle this common response + raise + else: + self.call = call + except Exception as e: + # Invalid region specified + msg = 'The streamlabs call specified ({}) is invalid.' \ + .format(call) + self.logger.warning(msg) + self.logger.debug('Socket Exception: %s' % str(e)) + raise TypeError(msg) + + # Store the alert_type + # only applicable when calling /alerts + try: + if alert_type not in STRMLABS_ALERTS: + # allow the outer except to handle this common response + raise + else: + self.alert_type = alert_type + except Exception as e: + # Invalid region specified + msg = 'The streamlabs alert type specified ({}) is invalid.' \ + .format(call) + self.logger.warning(msg) + self.logger.debug('Socket Exception: %s' % str(e)) + raise TypeError(msg) + + # params only applicable when calling /alerts + self.image_href = image_href + self.sound_href = sound_href + self.duration = duration + self.special_text_color = special_text_color + + # only applicable when calling /donations + # The amount of this donation. + self.amount = amount + + # only applicable when calling /donations + # The 3 letter currency code for this donation. + # Must be one of the supported currency codes. + self.currency = validate_regex( + currency, + *self.template_args['currency']['regex'] + ) + + # only applicable when calling /donations + if not self.currency: + msg = 'An invalid Streamslabs currency was specified.' + self.logger.warning(msg) + raise TypeError(msg) + + # only applicable when calling /donations + # The name of the donor + self.name = validate_regex( + name, + *self.template_args['name']['regex'] + ) + if not self.name: + msg = 'An invalid Streamslabs donor was specified.' + self.logger.warning(msg) + raise TypeError(msg) + + # An identifier for this donor, + # which is used to group donations with the same donor. + # only applicable when calling /donations + self.identifier = identifier + + return + + def send(self, body, title='', notify_type=NotifyType.INFO, attach=None, + **kwargs): + """ + Perform Streamlabs notification call (either donation or alert) + """ + + headers = { + 'User-Agent': self.app_id, + } + if self.call == StrmlabsCall.ALERT: + + data = { + 'access_token': self.access_token, + 'type': self.alert_type.lower(), + 'image_href': self.image_href, + 'sound_href': self.sound_href, + 'message': title, + 'user_massage': body, + 'duration': self.duration, + 'special_text_color': self.special_text_color, + } + + try: + r = requests.post( + self.notify_url + self.call.lower(), + headers=headers, + data=data, + verify=self.verify_certificate, + ) + if r.status_code != requests.codes.ok: + # We had a problem + status_str = \ + NotifyStreamlabs.http_response_code_lookup( + r.status_code) + + self.logger.warning( + 'Failed to send Streamlabs alert: ' + '{}{}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 Streamlabs alert.') + + except requests.RequestException as e: + self.logger.warning( + 'A Connection error occured sending Streamlabs ' + 'alert.' + ) + self.logger.debug('Socket Exception: %s' % str(e)) + return False + + if self.call == StrmlabsCall.DONATION: + data = { + 'name': self.name, + 'identifier': self.identifier, + 'amount': self.amount, + 'currency': self.currency, + 'access_token': self.access_token, + 'message': body, + } + + try: + r = requests.post( + self.notify_url + self.call.lower(), + headers=headers, + data=data, + verify=self.verify_certificate, + ) + if r.status_code != requests.codes.ok: + # We had a problem + status_str = \ + NotifyStreamlabs.http_response_code_lookup( + r.status_code) + + self.logger.warning( + 'Failed to send Streamlabs donation: ' + '{}{}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 Streamlabs donation.') + + except requests.RequestException as e: + self.logger.warning( + 'A Connection error occured sending Streamlabs ' + 'donation.' + ) + self.logger.debug('Socket Exception: %s' % str(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 = { + 'call': self.call, + # donation + 'name': self.name, + 'identifier': self.identifier, + 'amount': self.amount, + 'currency': self.currency, + # alert + 'alert_type': self.alert_type, + 'image_href': self.image_href, + 'sound_href': self.sound_href, + 'duration': self.duration, + 'special_text_color': self.special_text_color, + } + + # Extend our parameters + params.update(self.url_parameters(privacy=privacy, *args, **kwargs)) + return '{schema}://{access_token}/?{params}'.format( + schema=self.secure_protocol, + access_token=self.pprint(self.access_token, privacy, safe=''), + params=NotifyStreamlabs.urlencode(params), + ) + + @staticmethod + def parse_url(url): + """ + Parses the URL and returns enough arguments that can allow + us to re-instantiate this object. + + Syntax: + strmlabs://access_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 access code + access_token = NotifyStreamlabs.unquote(results['host']) + results['access_token'] = access_token + + # call + if 'call' in results['qsd'] and results['qsd']['call']: + results['call'] = NotifyStreamlabs.unquote( + results['qsd']['call'].strip().upper()) + # donation - amount + if 'amount' in results['qsd'] and results['qsd']['amount']: + results['amount'] = NotifyStreamlabs.unquote( + results['qsd']['amount']) + # donation - currency + if 'currency' in results['qsd'] and results['qsd']['currency']: + results['currency'] = NotifyStreamlabs.unquote( + results['qsd']['currency'].strip().upper()) + # donation - name + if 'name' in results['qsd'] and results['qsd']['name']: + results['name'] = NotifyStreamlabs.unquote( + results['qsd']['name'].strip().upper()) + # donation - identifier + if 'identifier' in results['qsd'] and results['qsd']['identifier']: + results['identifier'] = NotifyStreamlabs.unquote( + results['qsd']['identifier'].strip().upper()) + # alert - alert_type + if 'alert_type' in results['qsd'] and results['qsd']['alert_type']: + results['alert_type'] = NotifyStreamlabs.unquote( + results['qsd']['alert_type']) + # alert - image_href + if 'image_href' in results['qsd'] and results['qsd']['image_href']: + results['image_href'] = NotifyStreamlabs.unquote( + results['qsd']['image_href']) + # alert - sound_href + if 'sound_href' in results['qsd'] and results['qsd']['sound_href']: + results['sound_href'] = NotifyStreamlabs.unquote( + results['qsd']['sound_href'].strip().upper()) + # alert - duration + if 'duration' in results['qsd'] and results['qsd']['duration']: + results['duration'] = NotifyStreamlabs.unquote( + results['qsd']['duration'].strip().upper()) + # alert - special_text_color + if 'special_text_color' in results['qsd'] \ + and results['qsd']['special_text_color']: + results['special_text_color'] = NotifyStreamlabs.unquote( + results['qsd']['special_text_color'].strip().upper()) + + return results diff --git a/libs/apprise/plugins/NotifySyslog.py b/libs/apprise/plugins/NotifySyslog.py index 2457410e2..4fa9d915f 100644 --- a/libs/apprise/plugins/NotifySyslog.py +++ b/libs/apprise/plugins/NotifySyslog.py @@ -22,12 +22,15 @@ # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN # THE SOFTWARE. - +import os +import six import syslog +import socket from .NotifyBase import NotifyBase from ..common import NotifyType from ..utils import parse_bool +from ..utils import is_hostname from ..AppriseLocale import gettext_lazy as _ @@ -98,6 +101,21 @@ SYSLOG_FACILITY_RMAP = { } +class SyslogMode(object): + # A local query + LOCAL = "local" + + # A remote query + REMOTE = "remote" + + +# webhook modes are placed ito this list for validation purposes +SYSLOG_MODES = ( + SyslogMode.LOCAL, + SyslogMode.REMOTE, +) + + class NotifySyslog(NotifyBase): """ A wrapper for Syslog Notifications @@ -119,13 +137,14 @@ class NotifySyslog(NotifyBase): # local anyway request_rate_per_sec = 0 - # Title to be added to body if present - title_maxlen = 0 - # Define object templates templates = ( '{schema}://', '{schema}://{facility}', + '{schema}://{host}', + '{schema}://{host}:{port}', + '{schema}://{host}/{facility}', + '{schema}://{host}:{port}/{facility}', ) # Define our template tokens @@ -136,6 +155,18 @@ class NotifySyslog(NotifyBase): 'values': [k for k in SYSLOG_FACILITY_MAP.keys()], 'default': SyslogFacility.USER, }, + 'host': { + 'name': _('Hostname'), + 'type': 'string', + 'required': True, + }, + 'port': { + 'name': _('Port'), + 'type': 'int', + 'min': 1, + 'max': 65535, + 'default': 514, + }, }) # Define our template arguments @@ -144,6 +175,12 @@ class NotifySyslog(NotifyBase): # We map back to the same element defined in template_tokens 'alias_of': 'facility', }, + 'mode': { + 'name': _('Syslog Mode'), + 'type': 'choice:string', + 'values': SYSLOG_MODES, + 'default': SyslogMode.LOCAL, + }, 'logpid': { 'name': _('Log PID'), 'type': 'bool', @@ -158,8 +195,8 @@ class NotifySyslog(NotifyBase): }, }) - def __init__(self, facility=None, log_pid=True, log_perror=False, - **kwargs): + def __init__(self, facility=None, mode=None, log_pid=True, + log_perror=False, **kwargs): """ Initialize Syslog Object """ @@ -179,6 +216,14 @@ class NotifySyslog(NotifyBase): SYSLOG_FACILITY_MAP[ self.template_tokens['facility']['default']] + self.mode = self.template_args['mode']['default'] \ + if not isinstance(mode, six.string_types) else mode.lower() + + if self.mode not in SYSLOG_MODES: + msg = 'The mode specified ({}) is invalid.'.format(mode) + self.logger.warning(msg) + raise TypeError(msg) + # Logging Options self.logoptions = 0 @@ -214,17 +259,76 @@ class NotifySyslog(NotifyBase): NotifyType.WARNING: syslog.LOG_WARNING, } + if title: + # Format title + body = '{}: {}'.format(title, body) + # Always call throttle before any remote server i/o is made self.throttle() - try: - syslog.syslog(_pmap[notify_type], body) + if self.mode == SyslogMode.LOCAL: + try: + syslog.syslog(_pmap[notify_type], body) - except KeyError: - # An invalid notification type was specified - self.logger.warning( - 'An invalid notification type ' - '({}) was specified.'.format(notify_type)) - return False + except KeyError: + # An invalid notification type was specified + self.logger.warning( + 'An invalid notification type ' + '({}) was specified.'.format(notify_type)) + return False + + else: # SyslogMode.REMOTE + + host = self.host + port = self.port if self.port \ + else self.template_tokens['port']['default'] + if self.log_pid: + payload = '<%d>- %d - %s' % ( + _pmap[notify_type] + self.facility * 8, os.getpid(), body) + + else: + payload = '<%d>- %s' % ( + _pmap[notify_type] + self.facility * 8, body) + + # send UDP packet to upstream server + self.logger.debug( + 'Syslog Host: %s:%d/%s', + host, port, SYSLOG_FACILITY_RMAP[self.facility]) + self.logger.debug('Syslog Payload: %s' % str(payload)) + + # our sent bytes + sent = 0 + + try: + sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + sock.settimeout(self.socket_connect_timeout) + sent = sock.sendto(payload.encode('utf-8'), (host, port)) + sock.close() + + except socket.gaierror as e: + self.logger.warning( + 'A connection error occurred sending Syslog ' + 'notification to %s:%d/%s', host, port, + SYSLOG_FACILITY_RMAP[self.facility] + ) + self.logger.debug('Socket Exception: %s' % str(e)) + return False + + except socket.timeout as e: + self.logger.warning( + 'A connection timeout occurred sending Syslog ' + 'notification to %s:%d/%s', host, port, + SYSLOG_FACILITY_RMAP[self.facility] + ) + self.logger.debug('Socket Exception: %s' % str(e)) + return False + + if sent < len(payload): + self.logger.warning( + 'Syslog sent %d byte(s) but intended to send %d byte(s)', + sent, len(payload)) + return False + + self.logger.info('Sent Syslog (%s) notification.', self.mode) return True @@ -237,16 +341,31 @@ class NotifySyslog(NotifyBase): params = { 'logperror': 'yes' if self.log_perror else 'no', 'logpid': 'yes' if self.log_pid else 'no', + 'mode': self.mode, } # Extend our parameters params.update(self.url_parameters(privacy=privacy, *args, **kwargs)) - return '{schema}://{facility}/?{params}'.format( + if self.mode == SyslogMode.LOCAL: + return '{schema}://{facility}/?{params}'.format( + facility=self.template_tokens['facility']['default'] + if self.facility not in SYSLOG_FACILITY_RMAP + else SYSLOG_FACILITY_RMAP[self.facility], + schema=self.secure_protocol, + params=NotifySyslog.urlencode(params), + ) + + # Remote mode: + return '{schema}://{hostname}{port}/{facility}/?{params}'.format( + schema=self.secure_protocol, + hostname=NotifySyslog.quote(self.host, safe=''), + port='' if self.port is None + or self.port == self.template_tokens['port']['default'] + else ':{}'.format(self.port), facility=self.template_tokens['facility']['default'] if self.facility not in SYSLOG_FACILITY_RMAP else SYSLOG_FACILITY_RMAP[self.facility], - schema=self.secure_protocol, params=NotifySyslog.urlencode(params), ) @@ -262,9 +381,28 @@ class NotifySyslog(NotifyBase): # We're done early as we couldn't load the results return results - # if specified; save hostname into facility - facility = None if not results['host'] \ - else NotifySyslog.unquote(results['host']) + tokens = [] + if results['host']: + tokens.append(NotifySyslog.unquote(results['host'])) + + # Get our path values + tokens.extend(NotifySyslog.split_path(results['fullpath'])) + + facility = None + if len(tokens) > 1 and is_hostname(tokens[0]): + # syslog://hostname/facility + results['mode'] = SyslogMode.REMOTE + + # Store our facility as the first path entry + facility = tokens[-1] + + elif tokens: + # This is a bit ambigious... it could be either: + # syslog://facility -or- syslog://hostname + + # First lets test it as a facility; we'll correct this + # later on if nessisary + facility = tokens[-1] # However if specified on the URL, that will over-ride what was # identified @@ -280,15 +418,34 @@ class NotifySyslog(NotifyBase): facility = next((f for f in SYSLOG_FACILITY_MAP.keys() if f.startswith(facility)), facility) - # Save facility - results['facility'] = facility + # Attempt to solve our ambiguity + if len(tokens) == 1 and is_hostname(tokens[0]) and ( + results['port'] or facility not in SYSLOG_FACILITY_MAP): + + # facility is likely hostname; update our guessed mode + results['mode'] = SyslogMode.REMOTE + + # Reset our facility value + facility = None + + # Set mode if not otherwise set + if 'mode' in results['qsd'] and len(results['qsd']['mode']): + results['mode'] = NotifySyslog.unquote(results['qsd']['mode']) + + # Save facility if set + if facility: + results['facility'] = facility # Include PID as part of the message logged - results['log_pid'] = \ - parse_bool(results['qsd'].get('logpid', True)) + results['log_pid'] = parse_bool( + results['qsd'].get( + 'logpid', + NotifySyslog.template_args['logpid']['default'])) # Print to stderr as well. - results['log_perror'] = \ - parse_bool(results['qsd'].get('logperror', False)) + results['log_perror'] = parse_bool( + results['qsd'].get( + 'logperror', + NotifySyslog.template_args['logperror']['default'])) return results diff --git a/libs/apprise/plugins/NotifyTelegram.py b/libs/apprise/plugins/NotifyTelegram.py index 4bfd2d368..3d9d718ec 100644 --- a/libs/apprise/plugins/NotifyTelegram.py +++ b/libs/apprise/plugins/NotifyTelegram.py @@ -205,13 +205,23 @@ class NotifyTelegram(NotifyBase): 'default': True, 'map_to': 'detect_owner', }, + 'silent': { + 'name': _('Silent Notification'), + 'type': 'bool', + 'default': False, + }, + 'preview': { + 'name': _('Web Page Preview'), + 'type': 'bool', + 'default': False, + }, 'to': { 'alias_of': 'targets', }, }) def __init__(self, bot_token, targets, detect_owner=True, - include_image=False, **kwargs): + include_image=False, silent=None, preview=None, **kwargs): """ Initialize Telegram Object """ @@ -229,6 +239,14 @@ class NotifyTelegram(NotifyBase): # Parse our list self.targets = parse_list(targets) + # Define whether or not we should make audible alarms + self.silent = self.template_args['silent']['default'] \ + if silent is None else bool(silent) + + # Define whether or not we should display a web page preview + self.preview = self.template_args['preview']['default'] \ + if preview is None else bool(preview) + # if detect_owner is set to True, we will attempt to determine who # the bot owner is based on the first person who messaged it. This # is not a fool proof way of doing things as over time Telegram removes @@ -513,7 +531,12 @@ class NotifyTelegram(NotifyBase): 'sendMessage' ) - payload = {} + payload = { + # Notification Audible Control + 'disable_notification': self.silent, + # Display Web Page Preview (if possible) + 'disable_web_page_preview': not self.preview, + } # Prepare Email Message if self.notify_format == NotifyFormat.MARKDOWN: @@ -524,35 +547,73 @@ class NotifyTelegram(NotifyBase): body, ) - elif self.notify_format == NotifyFormat.HTML: - payload['parse_mode'] = 'HTML' - - # HTML Spaces ( ) and tabs ( ) aren't supported - # See https://core.telegram.org/bots/api#html-style - body = re.sub(' ?', ' ', body, re.I) + else: # HTML or TEXT - # Tabs become 3 spaces - body = re.sub(' ?', ' ', body, re.I) + # Use Telegram's HTML mode + payload['parse_mode'] = 'HTML' - if title: + # Telegram's HTML support doesn't like having HTML escaped + # characters passed into it. to handle this situation, we need to + # search the body for these sequences and convert them to the + # output the user expected + telegram_escape_html_dict = { # HTML Spaces ( ) and tabs ( ) aren't supported # See https://core.telegram.org/bots/api#html-style - title = re.sub(' ?', ' ', title, re.I) + r'nbsp': ' ', # Tabs become 3 spaces - title = re.sub(' ?', ' ', title, re.I) - - payload['text'] = '{}{}'.format( - '<b>{}</b>\r\n'.format(title) if title else '', - body, - ) + r'emsp': ' ', + + # Some characters get re-escaped by the Telegram upstream + # service so we need to convert these back, + r'apos': '\'', + r'quot': '"', + } + + # Create a regular expression from the dictionary keys + html_regex = re.compile("&(%s);?" % "|".join( + map(re.escape, telegram_escape_html_dict.keys())).lower(), + re.I) + + # For each match, look-up corresponding value in dictionary + # we look +1 to ignore the & that does not appear in the index + # we only look at the first 4 characters because we don't want to + # fail on ' as it's accepted (along with &apos - no + # semi-colon) + body = html_regex.sub( # pragma: no branch + lambda mo: telegram_escape_html_dict[ + mo.string[mo.start():mo.end()][1:5]], body) - else: # TEXT - payload['parse_mode'] = 'HTML' - - # Escape content - title = NotifyTelegram.escape_html(title, whitespace=False) - body = NotifyTelegram.escape_html(body, whitespace=False) + if title: + # For each match, look-up corresponding value in dictionary + # Indexing is explained above (for how the body is parsed) + title = html_regex.sub( # pragma: no branch + lambda mo: telegram_escape_html_dict[ + mo.string[mo.start():mo.end()][1:5]], title) + + if self.notify_format == NotifyFormat.TEXT: + telegram_escape_text_dict = { + # We need to escape characters that conflict with html + # entity blocks (< and >) when displaying text + r'>': '>', + r'<': '<', + } + + # Create a regular expression from the dictionary keys + text_regex = re.compile("(%s)" % "|".join( + map(re.escape, telegram_escape_text_dict.keys())).lower(), + re.I) + + # For each match, look-up corresponding value in dictionary + body = text_regex.sub( # pragma: no branch + lambda mo: telegram_escape_text_dict[ + mo.string[mo.start():mo.end()]], body) + + if title: + # For each match, look-up corresponding value in dictionary + title = text_regex.sub( # pragma: no branch + lambda mo: telegram_escape_text_dict[ + mo.string[mo.start():mo.end()]], title) payload['text'] = '{}{}'.format( '<b>{}</b>\r\n'.format(title) if title else '', @@ -679,6 +740,8 @@ class NotifyTelegram(NotifyBase): params = { 'image': self.include_image, 'detect': 'yes' if self.detect_owner else 'no', + 'silent': 'yes' if self.silent else 'no', + 'preview': 'yes' if self.preview else 'no', } # Extend our parameters @@ -762,6 +825,15 @@ class NotifyTelegram(NotifyBase): # Store our bot token results['bot_token'] = bot_token + # Silent (Sends the message Silently); users will receive + # notification with no sound. + results['silent'] = \ + parse_bool(results['qsd'].get('silent', False)) + + # Show Web Page Preview + results['preview'] = \ + parse_bool(results['qsd'].get('preview', False)) + # Include images with our message results['include_image'] = \ parse_bool(results['qsd'].get('image', False)) diff --git a/libs/apprise/plugins/NotifyTwilio.py b/libs/apprise/plugins/NotifyTwilio.py index 4ab19713f..883cc50ba 100644 --- a/libs/apprise/plugins/NotifyTwilio.py +++ b/libs/apprise/plugins/NotifyTwilio.py @@ -40,22 +40,18 @@ # or consider purchasing a short-code from here: # https://www.twilio.com/docs/glossary/what-is-a-short-code # -import re import requests from json import loads from .NotifyBase import NotifyBase from ..URLBase import PrivacyMode from ..common import NotifyType -from ..utils import parse_list +from ..utils import is_phone_no +from ..utils import parse_phone_no from ..utils import validate_regex from ..AppriseLocale import gettext_lazy as _ -# Some Phone Number Detection -IS_PHONE_NO = re.compile(r'^\+?(?P<phone>[0-9\s)(+-]+)\s*$') - - class NotifyTwilio(NotifyBase): """ A wrapper for Twilio Notifications @@ -112,7 +108,7 @@ class NotifyTwilio(NotifyBase): 'type': 'string', 'private': True, 'required': True, - 'regex': (r'^[a-f0-9]+$', 'i'), + 'regex': (r'^[a-z0-9]+$', 'i'), }, 'from_phone': { 'name': _('From Phone No'), @@ -154,10 +150,16 @@ class NotifyTwilio(NotifyBase): 'token': { 'alias_of': 'auth_token', }, + 'apikey': { + 'name': _('API Key'), + 'type': 'string', + 'private': True, + 'regex': (r'^SK[a-f0-9]+$', 'i'), + }, }) def __init__(self, account_sid, auth_token, source, targets=None, - **kwargs): + apikey=None, ** kwargs): """ Initialize Twilio Object """ @@ -181,17 +183,19 @@ class NotifyTwilio(NotifyBase): self.logger.warning(msg) raise TypeError(msg) - # The Source Phone # and/or short-code - self.source = source + # The API Key associated with the account (optional) + self.apikey = validate_regex( + apikey, *self.template_args['apikey']['regex']) - if not IS_PHONE_NO.match(self.source): + result = is_phone_no(source, min_len=5) + if not result: msg = 'The Account (From) Phone # or Short-code specified ' \ '({}) is invalid.'.format(source) self.logger.warning(msg) raise TypeError(msg) - # Tidy source - self.source = re.sub(r'[^\d]+', '', self.source) + # Store The Source Phone # and/or short-code + self.source = result['full'] if len(self.source) < 11 or len(self.source) > 14: # https://www.twilio.com/docs/glossary/what-is-a-short-code @@ -213,37 +217,18 @@ class NotifyTwilio(NotifyBase): # Parse our targets self.targets = list() - for target in parse_list(targets): + for target in parse_phone_no(targets): # Validate targets and drop bad ones: - result = IS_PHONE_NO.match(target) - if result: - # Further check our phone # for it's digit count - # if it's less than 10, then we can assume it's - # a poorly specified phone no and spit a warning - 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('+{}'.format(result)) + result = is_phone_no(target) + if not result: + self.logger.warning( + 'Dropped invalid phone # ' + '({}) specified.'.format(target), + ) continue - self.logger.warning( - 'Dropped invalid phone # ' - '({}) specified.'.format(target), - ) - - if not self.targets: - if len(self.source) in (5, 6): - # raise a warning since we're a short-code. We need - # a number to message - msg = 'There are no valid Twilio targets to notify.' - self.logger.warning(msg) - raise TypeError(msg) + # store valid phone number + self.targets.append('+{}'.format(result['full'])) return @@ -252,6 +237,14 @@ class NotifyTwilio(NotifyBase): Perform Twilio Notification """ + if not self.targets: + if len(self.source) in (5, 6): + # Generate a warning since we're a short-code. We need + # a number to message at minimum + self.logger.warning( + 'There are no valid Twilio targets to notify.') + return False + # error tracking (used for function return) has_error = False @@ -276,8 +269,8 @@ class NotifyTwilio(NotifyBase): # Create a copy of the targets list targets = list(self.targets) - # Set up our authentication - auth = (self.account_sid, self.auth_token) + # Set up our authentication. Prefer the API Key if provided. + auth = (self.apikey or self.account_sid, self.auth_token) if len(targets) == 0: # No sources specified, use our own phone no @@ -371,6 +364,10 @@ class NotifyTwilio(NotifyBase): # Our URL parameters params = self.url_parameters(privacy=privacy, *args, **kwargs) + if self.apikey is not None: + # apikey specified; pass it back on the url + params['apikey'] = self.apikey + return '{schema}://{sid}:{token}@{source}/{targets}/?{params}'.format( schema=self.secure_protocol, sid=self.pprint( @@ -417,6 +414,10 @@ class NotifyTwilio(NotifyBase): results['account_sid'] = \ NotifyTwilio.unquote(results['qsd']['sid']) + # API Key + if 'apikey' in results['qsd'] and len(results['qsd']['apikey']): + results['apikey'] = results['qsd']['apikey'] + # Support the 'from' and 'source' variable so that we can support # targets this way too. # The 'from' makes it easier to use yaml configuration @@ -431,6 +432,6 @@ class NotifyTwilio(NotifyBase): # The 'to' makes it easier to use yaml configuration if 'to' in results['qsd'] and len(results['qsd']['to']): results['targets'] += \ - NotifyTwilio.parse_list(results['qsd']['to']) + NotifyTwilio.parse_phone_no(results['qsd']['to']) return results diff --git a/libs/apprise/plugins/NotifyTwist.py b/libs/apprise/plugins/NotifyTwist.py index 39bec5eaa..7f9c7c889 100644 --- a/libs/apprise/plugins/NotifyTwist.py +++ b/libs/apprise/plugins/NotifyTwist.py @@ -562,6 +562,7 @@ class NotifyTwist(NotifyBase): if not len(self.channel_ids): # We have nothing to notify + self.logger.warning('There are no Twist targets to notify') return False # Notify all of our identified channels @@ -789,7 +790,7 @@ class NotifyTwist(NotifyBase): try: self.logout() - except LookupError: + except LookupError: # pragma: no cover # Python v3.5 call to requests can sometimes throw the exception # "/usr/lib64/python3.7/socket.py", line 748, in getaddrinfo # LookupError: unknown encoding: idna @@ -804,3 +805,28 @@ class NotifyTwist(NotifyBase): # ticket system as unresolved and has provided work-arounds # - https://github.com/kennethreitz/requests/issues/3578 pass + + except ImportError: # pragma: no cover + # The actual exception is `ModuleNotFoundError` however ImportError + # grants us backwards compatiblity with versions of Python older + # than v3.6 + + # Python code that makes early calls to sys.exit() can cause + # the __del__() code to run. However in some newer versions of + # Python, this causes the `sys` library to no longer be + # available. The stack overflow also goes on to suggest that + # it's not wise to use the __del__() as a deconstructor + # which is the case here. + + # https://stackoverflow.com/questions/67218341/\ + # modulenotfounderror-import-of-time-halted-none-in-sys-\ + # modules-occured-when-obj?noredirect=1&lq=1 + # + # + # Also see: https://stackoverflow.com/questions\ + # /1481488/what-is-the-del-method-and-how-do-i-call-it + + # At this time it seems clean to try to log out (if we can) + # but not throw any unessisary exceptions (like this one) to + # the end user if we don't have to. + pass diff --git a/libs/apprise/plugins/NotifyWindows.py b/libs/apprise/plugins/NotifyWindows.py index 9c957f9df..b03d375b1 100644 --- a/libs/apprise/plugins/NotifyWindows.py +++ b/libs/apprise/plugins/NotifyWindows.py @@ -56,6 +56,13 @@ class NotifyWindows(NotifyBase): """ A wrapper for local Windows Notifications """ + # Set our global enabled flag + enabled = NOTIFY_WINDOWS_SUPPORT_ENABLED + + requirements = { + # Define our required packaging in order to work + 'details': _('A local Microsoft Windows environment is required.') + } # The default descriptive name associated with the Notification service_name = 'Windows Notification' @@ -80,15 +87,6 @@ class NotifyWindows(NotifyBase): # The number of seconds to display the popup for default_popup_duration_sec = 12 - # This entry is a bit hacky, but it allows us to unit-test this library - # in an environment that simply doesn't have the windows packages - # available to us. It also allows us to handle situations where the - # packages actually are present but we need to test that they aren't. - # If anyone is seeing this had knows a better way of testing this - # outside of what is defined in test/test_windows_plugin.py, please - # let me know! :) - _enabled = NOTIFY_WINDOWS_SUPPORT_ENABLED - # Define object templates templates = ( '{schema}://', @@ -144,12 +142,6 @@ class NotifyWindows(NotifyBase): Perform Windows Notification """ - if not self._enabled: - self.logger.warning( - "Windows Notifications are not supported by this system; " - "`pip install pywin32`.") - return False - # Always call throttle before any remote server i/o is made self.throttle() diff --git a/libs/apprise/plugins/NotifyXML.py b/libs/apprise/plugins/NotifyXML.py index 21ddf0b64..39438fada 100644 --- a/libs/apprise/plugins/NotifyXML.py +++ b/libs/apprise/plugins/NotifyXML.py @@ -26,6 +26,7 @@ import re import six import requests +import base64 from .NotifyBase import NotifyBase from ..URLBase import PrivacyMode @@ -58,6 +59,11 @@ class NotifyXML(NotifyBase): # local anyway request_rate_per_sec = 0 + # XSD Information + xsd_ver = '1.1' + xsd_url = 'https://raw.githubusercontent.com/caronc/apprise/master' \ + '/apprise/assets/NotifyXML-{version}.xsd' + # Define object templates templates = ( '{schema}://{host}', @@ -118,11 +124,12 @@ class NotifyXML(NotifyBase): xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"> <soapenv:Body> - <Notification xmlns:xsi="http://nuxref.com/apprise/NotifyXML-1.0.xsd"> - <Version>1.0</Version> + <Notification xmlns:xsi="{XSD_URL}"> + <Version>{XSD_VER}</Version> <Subject>{SUBJECT}</Subject> <MessageType>{MESSAGE_TYPE}</MessageType> <Message>{MESSAGE}</Message> + {ATTACHMENTS} </Notification> </soapenv:Body> </soapenv:Envelope>""" @@ -175,7 +182,8 @@ class NotifyXML(NotifyBase): params=NotifyXML.urlencode(params), ) - def send(self, body, title='', notify_type=NotifyType.INFO, **kwargs): + def send(self, body, title='', notify_type=NotifyType.INFO, attach=None, + **kwargs): """ Perform XML Notification """ @@ -189,11 +197,55 @@ class NotifyXML(NotifyBase): # Apply any/all header over-rides defined headers.update(self.headers) + # Our XML Attachmement subsitution + xml_attachments = '' + + # Track our potential attachments + attachments = [] + if attach: + for attachment in attach: + # 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: + with open(attachment.path, 'rb') as f: + # Output must be in a DataURL format (that's what + # PushSafer calls it): + entry = \ + '<Attachment filename="{}" mimetype="{}">'.format( + NotifyXML.escape_html( + attachment.name, whitespace=False), + NotifyXML.escape_html( + attachment.mimetype, whitespace=False)) + entry += base64.b64encode(f.read()).decode('utf-8') + entry += '</Attachment>' + attachments.append(entry) + + 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 + + # Update our xml_attachments record: + xml_attachments = \ + '<Attachments format="base64">' + \ + ''.join(attachments) + '</Attachments>' + re_map = { + '{XSD_VER}': self.xsd_ver, + '{XSD_URL}': self.xsd_url.format(version=self.xsd_ver), '{MESSAGE_TYPE}': NotifyXML.escape_html( notify_type, whitespace=False), '{SUBJECT}': NotifyXML.escape_html(title, whitespace=False), '{MESSAGE}': NotifyXML.escape_html(body, whitespace=False), + '{ATTACHMENTS}': xml_attachments, } # Iterate over above list and store content accordingly @@ -219,6 +271,7 @@ class NotifyXML(NotifyBase): self.logger.debug('XML POST URL: %s (cert_verify=%r)' % ( url, self.verify_certificate, )) + self.logger.debug('XML Payload: %s' % str(payload)) # Always call throttle before any remote server i/o is made @@ -278,8 +331,12 @@ class NotifyXML(NotifyBase): # 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+']) + results['headers'] = results['qsd+'] + if results['qsd-']: + results['headers'].update(results['qsd-']) + NotifyBase.logger.deprecate( + "minus (-) based XML header tokens are being " + "removed; use the plus (+) symbol instead.") # Tidy our header entries by unquoting them results['headers'] = {NotifyXML.unquote(x): NotifyXML.unquote(y) diff --git a/libs/apprise/plugins/NotifyXMPP/SleekXmppAdapter.py b/libs/apprise/plugins/NotifyXMPP/SliXmppAdapter.py index a28e9ce54..9450e70d7 100644 --- a/libs/apprise/plugins/NotifyXMPP/SleekXmppAdapter.py +++ b/libs/apprise/plugins/NotifyXMPP/SliXmppAdapter.py @@ -6,23 +6,24 @@ import logging # Default our global support flag -SLEEKXMPP_SUPPORT_AVAILABLE = False +SLIXMPP_SUPPORT_AVAILABLE = False try: - # Import sleekxmpp if available - import sleekxmpp + # Import slixmpp if available + import slixmpp + import asyncio - SLEEKXMPP_SUPPORT_AVAILABLE = True + SLIXMPP_SUPPORT_AVAILABLE = True except ImportError: # No problem; we just simply can't support this plugin because we're - # either using Linux, or simply do not have sleekxmpp installed. + # either using Linux, or simply do not have slixmpp installed. pass -class SleekXmppAdapter(object): +class SliXmppAdapter(object): """ - Wrapper to sleekxmpp + Wrapper to slixmpp """ @@ -38,12 +39,6 @@ class SleekXmppAdapter(object): # The default secure protocol secure_protocol = 'xmpps' - # The default XMPP port - default_unsecure_port = 5222 - - # The default XMPP secure port - default_secure_port = 5223 - # Taken from https://golang.org/src/crypto/x509/root_linux.go CA_CERTIFICATE_FILE_LOCATIONS = [ # Debian/Ubuntu/Gentoo etc. @@ -59,19 +54,20 @@ class SleekXmppAdapter(object): ] # This entry is a bit hacky, but it allows us to unit-test this library - # in an environment that simply doesn't have the sleekxmpp package + # in an environment that simply doesn't have the slixmpp package # available to us. # # If anyone is seeing this had knows a better way of testing this # outside of what is defined in test/test_xmpp_plugin.py, please # let me know! :) - _enabled = SLEEKXMPP_SUPPORT_AVAILABLE + _enabled = SLIXMPP_SUPPORT_AVAILABLE def __init__(self, host=None, port=None, secure=False, verify_certificate=True, xep=None, jid=None, password=None, - body=None, targets=None, before_message=None, logger=None): + body=None, subject=None, targets=None, before_message=None, + logger=None): """ - Initialize our SleekXmppAdapter object + Initialize our SliXmppAdapter object """ self.host = host @@ -84,25 +80,35 @@ class SleekXmppAdapter(object): self.password = password self.body = body + self.subject = subject self.targets = targets self.before_message = before_message self.logger = logger or logging.getLogger(__name__) - # Use the Apprise log handlers for configuring the sleekxmpp logger. + # Use the Apprise log handlers for configuring the slixmpp logger. apprise_logger = logging.getLogger('apprise') - sleek_logger = logging.getLogger('sleekxmpp') + sli_logger = logging.getLogger('slixmpp') for handler in apprise_logger.handlers: - sleek_logger.addHandler(handler) - sleek_logger.setLevel(apprise_logger.level) + sli_logger.addHandler(handler) + sli_logger.setLevel(apprise_logger.level) if not self.load(): raise ValueError("Invalid XMPP Configuration") def load(self): + try: + asyncio.get_event_loop() + + except RuntimeError: + # slixmpp can not handle not having an event_loop + # see: https://lab.louiz.org/poezio/slixmpp/-/issues/3456 + # This is a work-around to this problem + asyncio.set_event_loop(asyncio.new_event_loop()) + # Prepare our object - self.xmpp = sleekxmpp.ClientXMPP(self.jid, self.password) + self.xmpp = slixmpp.ClientXMPP(self.jid, self.password) # Register our session self.xmpp.add_event_handler("session_start", self.session_start) @@ -112,7 +118,7 @@ class SleekXmppAdapter(object): try: self.xmpp.register_plugin('xep_{0:04d}'.format(xep)) - except sleekxmpp.plugins.base.PluginNotFound: + except slixmpp.plugins.base.PluginNotFound: self.logger.warning( 'Could not register plugin {}'.format( 'xep_{0:04d}'.format(xep))) @@ -141,6 +147,11 @@ class SleekXmppAdapter(object): 'no local CA certificate file') return False + # If the user specified a port, skip SRV resolving, otherwise it is a + # lot easier to let slixmpp handle DNS instead of the user. + self.override_connection = \ + None if not self.port else (self.host, self.port) + # We're good return True @@ -150,32 +161,14 @@ class SleekXmppAdapter(object): """ - # Establish connection to XMPP server. - # To speed up sending messages, don't use the "reattempt" feature, - # it will add a nasty delay even before connecting to XMPP server. - if not self.xmpp.connect((self.host, self.port), - use_ssl=self.secure, reattempt=False): - - default_port = self.default_secure_port \ - if self.secure else self.default_unsecure_port - - default_schema = self.secure_protocol \ - if self.secure else self.protocol - - # Log connection issue - self.logger.warning( - 'Failed to authenticate {jid} with: {schema}://{host}{port}' - .format( - jid=self.jid, - schema=default_schema, - host=self.host, - port='' if not self.port or self.port == default_port - else ':{}'.format(self.port), - )) + # Instruct slixmpp to connect to the XMPP service. + if not self.xmpp.connect( + self.override_connection, use_ssl=self.secure): return False - # Process XMPP communication. - self.xmpp.process(block=True) + # Run the asyncio event loop, and return once disconnected, + # for any reason. + self.xmpp.process(forever=False) return self.success @@ -198,7 +191,9 @@ class SleekXmppAdapter(object): self.before_message() # The message we wish to send, and the JID that will receive it. - self.xmpp.send_message(mto=target, mbody=self.body, mtype='chat') + self.xmpp.send_message( + mto=target, msubject=self.subject, + mbody=self.body, mtype='chat') # Using wait=True ensures that the send queue will be # emptied before ending the session. diff --git a/libs/apprise/plugins/NotifyXMPP/__init__.py b/libs/apprise/plugins/NotifyXMPP/__init__.py index 48dbc19b0..d5fb9a2c9 100644 --- a/libs/apprise/plugins/NotifyXMPP/__init__.py +++ b/libs/apprise/plugins/NotifyXMPP/__init__.py @@ -30,7 +30,7 @@ from ...URLBase import PrivacyMode from ...common import NotifyType from ...utils import parse_list from ...AppriseLocale import gettext_lazy as _ -from .SleekXmppAdapter import SleekXmppAdapter +from .SliXmppAdapter import SliXmppAdapter # xep string parser XEP_PARSE_RE = re.compile('^[^1-9]*(?P<xep>[1-9][0-9]{0,3})$') @@ -40,10 +40,22 @@ class NotifyXMPP(NotifyBase): """ A wrapper for XMPP Notifications """ + # Set our global enabled flag + enabled = SliXmppAdapter._enabled + + requirements = { + # Define our required packaging in order to work + 'packages_required': [ + "slixmpp; python_version >= '3.7'", + ] + } # The default descriptive name associated with the Notification service_name = 'XMPP' + # The services URL + service_url = 'https://xmpp.org/' + # The default protocol protocol = 'xmpp' @@ -56,34 +68,13 @@ class NotifyXMPP(NotifyBase): # Lower throttle rate for XMPP request_rate_per_sec = 0.5 - # The default XMPP port - default_unsecure_port = 5222 - - # The default XMPP secure port - default_secure_port = 5223 - - # XMPP does not support a title - title_maxlen = 0 - - # This entry is a bit hacky, but it allows us to unit-test this library - # in an environment that simply doesn't have the sleekxmpp package - # available to us. - # - # If anyone is seeing this had knows a better way of testing this - # outside of what is defined in test/test_xmpp_plugin.py, please - # let me know! :) - _enabled = SleekXmppAdapter._enabled + # Our XMPP Adapter we use to communicate through + _adapter = SliXmppAdapter if SliXmppAdapter._enabled else None # Define object templates templates = ( - '{schema}://{host}', - '{schema}://{password}@{host}', - '{schema}://{password}@{host}:{port}', '{schema}://{user}:{password}@{host}', '{schema}://{user}:{password}@{host}:{port}', - '{schema}://{host}/{targets}', - '{schema}://{password}@{host}/{targets}', - '{schema}://{password}@{host}:{port}/{targets}', '{schema}://{user}:{password}@{host}/{targets}', '{schema}://{user}:{password}@{host}:{port}/{targets}', ) @@ -104,6 +95,7 @@ class NotifyXMPP(NotifyBase): 'user': { 'name': _('Username'), 'type': 'string', + 'required': True, }, 'password': { 'name': _('Password'), @@ -214,6 +206,7 @@ class NotifyXMPP(NotifyBase): # By default we send ourselves a message if targets: self.targets = parse_list(targets) + self.targets[0] = self.targets[0][1:] else: self.targets = list() @@ -223,40 +216,20 @@ class NotifyXMPP(NotifyBase): Perform XMPP Notification """ - if not self._enabled: - self.logger.warning( - 'XMPP Notifications are not supported by this system ' - '- install sleekxmpp.') - return False - # Detect our JID if it isn't otherwise specified jid = self.jid password = self.password if not jid: - if self.user and self.password: - # xmpp://user:password@hostname - jid = '{}@{}'.format(self.user, self.host) - - else: - # xmpp://password@hostname - jid = self.host - password = self.password if self.password else self.user - - # Compute port number - if not self.port: - port = self.default_secure_port \ - if self.secure else self.default_unsecure_port - - else: - port = self.port + jid = '{}@{}'.format(self.user, self.host) try: # Communicate with XMPP. - xmpp_adapter = SleekXmppAdapter( - host=self.host, port=port, secure=self.secure, + xmpp_adapter = self._adapter( + host=self.host, port=self.port, secure=self.secure, verify_certificate=self.verify_certificate, xep=self.xep, - jid=jid, password=password, body=body, targets=self.targets, - before_message=self.throttle, logger=self.logger) + jid=jid, password=password, body=body, subject=title, + targets=self.targets, before_message=self.throttle, + logger=self.logger) except ValueError: # We failed @@ -287,28 +260,19 @@ class NotifyXMPP(NotifyBase): # and/or space as a delimiters - %20 = space jids = '%20'.join([NotifyXMPP.quote(x, safe='') for x in self.targets]) - default_port = self.default_secure_port \ - if self.secure else self.default_unsecure_port - default_schema = self.secure_protocol if self.secure else self.protocol - if self.user and self.password: - auth = '{user}:{password}'.format( - user=NotifyXMPP.quote(self.user, safe=''), - password=self.pprint( - self.password, privacy, mode=PrivacyMode.Secret, safe='')) - - else: - auth = self.pprint( - self.password if self.password else self.user, privacy, - mode=PrivacyMode.Secret, safe='') + auth = '{user}:{password}'.format( + user=NotifyXMPP.quote(self.user, safe=''), + password=self.pprint( + self.password, privacy, mode=PrivacyMode.Secret, safe='')) return '{schema}://{auth}@{hostname}{port}/{jids}?{params}'.format( auth=auth, schema=default_schema, # never encode hostname since we're expecting it to be a valid one hostname=self.host, - port='' if not self.port or self.port == default_port + port='' if not self.port else ':{}'.format(self.port), jids=jids, params=NotifyXMPP.urlencode(params), diff --git a/libs/apprise/plugins/NotifyZulip.py b/libs/apprise/plugins/NotifyZulip.py index 2290efb0d..80ca94227 100644 --- a/libs/apprise/plugins/NotifyZulip.py +++ b/libs/apprise/plugins/NotifyZulip.py @@ -77,12 +77,12 @@ ZULIP_HTTP_ERROR_MAP = { 401: 'Unauthorized - Invalid Token.', } -# Used to break path apart into list of channels +# Used to break path apart into list of streams TARGET_LIST_DELIM = re.compile(r'[ \t\r\n,#\\/]+') -# Used to detect a channel +# Used to detect a streams IS_VALID_TARGET_RE = re.compile( - r'#?(?P<channel>[A-Z0-9_]{1,32})', re.I) + r'#?(?P<stream>[A-Z0-9_]{1,32})', re.I) class NotifyZulip(NotifyBase): @@ -142,8 +142,8 @@ class NotifyZulip(NotifyBase): 'type': 'string', 'map_to': 'targets', }, - 'target_channel': { - 'name': _('Target Channel'), + 'target_stream': { + 'name': _('Target Stream'), 'type': 'string', 'map_to': 'targets', }, @@ -164,8 +164,8 @@ class NotifyZulip(NotifyBase): # if one isn't defined in the apprise url default_hostname = 'zulipchat.com' - # The default channel to notify if no targets are specified - default_notification_channel = 'general' + # The default stream to notify if no targets are specified + default_notification_stream = 'general' def __init__(self, botname, organization, token, targets=None, **kwargs): """ @@ -218,8 +218,8 @@ class NotifyZulip(NotifyBase): self.targets = parse_list(targets) if len(self.targets) == 0: - # No channels identified, use default - self.targets.append(self.default_notification_channel) + # No streams identified, use default + self.targets.append(self.default_notification_stream) def send(self, body, title='', notify_type=NotifyType.INFO, **kwargs): """ diff --git a/libs/apprise/plugins/__init__.py b/libs/apprise/plugins/__init__.py index 22d938771..08a27d945 100644 --- a/libs/apprise/plugins/__init__.py +++ b/libs/apprise/plugins/__init__.py @@ -33,7 +33,7 @@ from os.path import abspath # Used for testing from . import NotifyEmail as NotifyEmailBase -from .NotifyXMPP import SleekXmppAdapter +from .NotifyXMPP import SliXmppAdapter # NotifyBase object is passed in as a module not class from . import NotifyBase @@ -43,6 +43,7 @@ from ..common import NOTIFY_IMAGE_SIZES from ..common import NotifyType from ..common import NOTIFY_TYPES from ..utils import parse_list +from ..utils import cwe312_url from ..utils import GET_SCHEMA_RE from ..logger import logger from ..AppriseLocale import gettext_lazy as _ @@ -62,8 +63,8 @@ __all__ = [ # Tokenizer 'url_to_dict', - # sleekxmpp access points (used for NotifyXMPP Testing) - 'SleekXmppAdapter', + # slixmpp access points (used for NotifyXMPP Testing) + 'SliXmppAdapter', ] # we mirror our base purely for the ability to reset everything; this @@ -438,7 +439,93 @@ def details(plugin): } -def url_to_dict(url): +def requirements(plugin): + """ + Provides a list of packages and its requirement details + + """ + 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 + 'details': '', + + # Define any required packages needed for the plugin to run. This is + # an array of strings that simply look like lines in the + # `requirements.txt` file... + # + # A single string is perfectly acceptable: + # 'packages_required' = 'cryptography' + # + # Multiple entries should look like the following + # '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': [], + } + + # Populate our template differently if we don't find anything above + if not (hasattr(plugin, 'requirements') + and isinstance(plugin.requirements, dict)): + # We're done early + return requirements + + # Get our required packages + _req_packages = plugin.requirements.get('packages_required') + if isinstance(_req_packages, six.string_types): + # Convert to list + _req_packages = [_req_packages] + + elif not isinstance(_req_packages, (set, list, tuple)): + # Allow one to set the required packages to None (as an example) + _req_packages = [] + + requirements['packages_required'] = [str(p) for p in _req_packages] + + # Get our recommended packages + _opt_packages = plugin.requirements.get('packages_recommended') + if isinstance(_opt_packages, six.string_types): + # Convert to list + _opt_packages = [_opt_packages] + + elif not isinstance(_opt_packages, (set, list, tuple)): + # Allow one to set the recommended packages to None (as an example) + _opt_packages = [] + + requirements['packages_recommended'] = [str(p) for p in _opt_packages] + + # Get our package details + _req_details = plugin.requirements.get('details') + if not _req_details: + if not (_req_packages or _opt_packages): + _req_details = _('No dependencies.') + + elif _req_packages: + _req_details = _('Packages are required to function.') + + else: # opt_packages + _req_details = \ + _('Packages are recommended to improve functionality.') + else: + # Store our details if defined + requirements['details'] = _req_details + + # Return our compiled package requirements + return requirements + + +def url_to_dict(url, secure_logging=True): """ Takes an apprise URL and returns the tokens associated with it if they can be acquired based on the plugins available. @@ -453,13 +540,16 @@ def url_to_dict(url): # swap hash (#) tag values with their html version _url = url.replace('/#', '/%23') + # CWE-312 (Secure Logging) Handling + loggable_url = url if not secure_logging else cwe312_url(url) + # Attempt to acquire the schema at the very least to allow our plugins to # determine if they can make a better interpretation of a URL geared for # them. schema = GET_SCHEMA_RE.match(_url) if schema is None: # Not a valid URL; take an early exit - logger.error('Unsupported URL: {}'.format(url)) + logger.error('Unsupported URL: {}'.format(loggable_url)) return None # Ensure our schema is always in lower case @@ -476,7 +566,7 @@ def url_to_dict(url): None) if not results: - logger.error('Unparseable URL {}'.format(url)) + logger.error('Unparseable URL {}'.format(loggable_url)) return None logger.trace('URL {} unpacked as:{}{}'.format( @@ -489,7 +579,7 @@ def url_to_dict(url): results = SCHEMA_MAP[schema].parse_url(_url) if not results: logger.error('Unparseable {} URL {}'.format( - SCHEMA_MAP[schema].service_name, url)) + SCHEMA_MAP[schema].service_name, loggable_url)) return None logger.trace('{} URL {} unpacked as:{}{}'.format( diff --git a/libs/apprise/py.typed b/libs/apprise/py.typed new file mode 100644 index 000000000..e69de29bb --- /dev/null +++ b/libs/apprise/py.typed diff --git a/libs/apprise/py3compat/asyncio.py b/libs/apprise/py3compat/asyncio.py index 85519fa2a..3724c8a02 100644 --- a/libs/apprise/py3compat/asyncio.py +++ b/libs/apprise/py3compat/asyncio.py @@ -25,6 +25,7 @@ import sys import asyncio +from functools import partial from ..URLBase import URLBase from ..logger import logger @@ -35,60 +36,61 @@ ASYNCIO_RUN_SUPPORT = \ (sys.version_info.major == 3 and sys.version_info.minor >= 7) -def notify(coroutines, debug=False): +# async reference produces a SyntaxError (E999) in Python v2.7 +# For this reason we turn on the noqa flag +async def notify(coroutines): # noqa: E999 """ - A Wrapper to the AsyncNotifyBase.async_notify() calls allowing us + An async wrapper to the AsyncNotifyBase.async_notify() calls allowing us to call gather() and collect the responses """ # Create log entry logger.info( - 'Notifying {} service(s) asynchronous.'.format(len(coroutines))) + 'Notifying {} service(s) asynchronously.'.format(len(coroutines))) + + results = await asyncio.gather(*coroutines, return_exceptions=True) + + # Returns True if all notifications succeeded, otherwise False is + # returned. + failed = any(not status or isinstance(status, Exception) + for status in results) + return not failed + + +def tosync(cor, debug=False): + """ + Await a coroutine from non-async code. + """ if ASYNCIO_RUN_SUPPORT: - # async reference produces a SyntaxError (E999) in Python v2.7 - # For this reason we turn on the noqa flag - async def main(results, coroutines): # noqa: E999 - """ - Task: Notify all servers specified and return our result set - through a mutable object. - """ - # send our notifications and store our result set into - # our results dictionary - results['response'] = \ - await asyncio.gather(*coroutines, return_exceptions=True) - - # Initialize a mutable object we can populate with our notification - # responses - results = {} - - # Send our notifications - asyncio.run(main(results, coroutines), debug=debug) - - # Acquire our return status - status = next((s for s in results['response'] if s is False), True) + return asyncio.run(cor, debug=debug) else: - # - # The depricated way - # + # The Deprecated Way (<= Python v3.6) + try: + # acquire access to our event loop + loop = asyncio.get_event_loop() - # acquire access to our event loop - loop = asyncio.get_event_loop() + except RuntimeError: + # This happens if we're inside a thread of another application + # where there is no running event_loop(). Pythong v3.7 and + # higher automatically take care of this case for us. But for + # the lower versions we need to do the following: + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) - if debug: - # Enable debug mode - loop.set_debug(1) + # Enable debug mode + loop.set_debug(debug) - # Send our notifications and acquire our status - results = loop.run_until_complete(asyncio.gather(*coroutines)) + return loop.run_until_complete(cor) - # Acquire our return status - status = next((r for r in results if r is False), True) - # Returns True if all notifications succeeded, otherwise False is - # returned. - return status +async def toasyncwrap(v): # noqa: E999 + """ + Create a coroutine that, when run, returns the provided value. + """ + + return v class AsyncNotifyBase(URLBase): @@ -100,8 +102,12 @@ class AsyncNotifyBase(URLBase): """ Async Notification Wrapper """ + + loop = asyncio.get_event_loop() + try: - return self.notify(*args, **kwargs) + return await loop.run_in_executor( + None, partial(self.notify, *args, **kwargs)) except TypeError: # These our our internally thrown notifications diff --git a/libs/apprise/utils.py b/libs/apprise/utils.py index 8d0920071..27b263c34 100644 --- a/libs/apprise/utils.py +++ b/libs/apprise/utils.py @@ -25,6 +25,7 @@ import re import six +import json import contextlib import os from os.path import expanduser @@ -95,9 +96,10 @@ TIDY_NUX_TRIM_RE = re.compile( # The handling of custom arguments passed in the URL; we treat any # argument (which would otherwise appear in the qsd area of our parse_url() -# function differently if they start with a + or - value +# function differently if they start with a +, - or : value NOTIFY_CUSTOM_ADD_TOKENS = re.compile(r'^( |\+)(?P<key>.*)\s*') NOTIFY_CUSTOM_DEL_TOKENS = re.compile(r'^-(?P<key>.*)\s*') +NOTIFY_CUSTOM_COLON_TOKENS = re.compile(r'^:(?P<key>.*)\s*') # Used for attempting to acquire the schema if the URL can't be parsed. GET_SCHEMA_RE = re.compile(r'\s*(?P<schema>[a-z0-9]{2,9})://.*$', re.I) @@ -113,18 +115,23 @@ GET_SCHEMA_RE = re.compile(r'\s*(?P<schema>[a-z0-9]{2,9})://.*$', re.I) GET_EMAIL_RE = re.compile( - r'((?P<name>[^:<]+)?[:<\s]+)?' + r'(([\s"\']+)?(?P<name>[^:<"\']+)?[:<\s"\']+)?' r'(?P<full_email>((?P<label>[^+]+)\+)?' r'(?P<email>(?P<userid>[a-z0-9$%=_~-]+' r'(?:\.[a-z0-9$%+=_~-]+)' r'*)@(?P<domain>(' - r'(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+' - r'[a-z0-9](?:[a-z0-9-]*[a-z0-9]))|' - r'[a-z0-9][a-z0-9-]{5,})))' + r'(?:[a-z0-9](?:[a-z0-9_-]*[a-z0-9])?\.)+' + r'[a-z0-9](?:[a-z0-9_-]*[a-z0-9]))|' + r'[a-z0-9][a-z0-9_-]{5,})))' r'\s*>?', re.IGNORECASE) -# Regular expression used to extract a phone number -GET_PHONE_NO_RE = re.compile(r'^\+?(?P<phone>[0-9\s)(+-]+)\s*$') +# A simple verification check to make sure the content specified +# rougly conforms to a phone number before we parse it further +IS_PHONE_NO = re.compile(r'^\+?(?P<phone>[0-9\s)(+-]+)\s*$') + +# Regular expression used to destinguish between multiple phone numbers +PHONE_NO_DETECTION_RE = re.compile( + r'\s*([+(\s]*[0-9][0-9()\s-]+[0-9])(?=$|[\s,+(]+[0-9])', re.I) # Regular expression used to destinguish between multiple URLs URL_DETECTION_RE = re.compile( @@ -136,11 +143,29 @@ EMAIL_DETECTION_RE = re.compile( r'[^@\s,]+@[^\s,]+)', re.IGNORECASE) +# Used to prepare our UUID regex matching +UUID4_RE = re.compile( + r'[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}', + re.IGNORECASE) + # validate_regex() utilizes this mapping to track and re-use pre-complied # regular expressions REGEX_VALIDATE_LOOKUP = {} +class TemplateType(object): + """ + Defines the different template types we can perform parsing on + """ + # RAW does nothing at all to the content being parsed + # data is taken at it's absolute value + RAW = 'raw' + + # Data is presumed to be of type JSON and is therefore escaped + # if required to do so (such as single quotes) + JSON = 'json' + + def is_ipaddr(addr, ipv4=True, ipv6=True): """ Validates against IPV4 and IPV6 IP Addresses @@ -191,7 +216,7 @@ def is_ipaddr(addr, ipv4=True, ipv6=True): return False -def is_hostname(hostname, ipv4=True, ipv6=True): +def is_hostname(hostname, ipv4=True, ipv6=True, underscore=True): """ Validate hostname """ @@ -200,7 +225,7 @@ def is_hostname(hostname, ipv4=True, ipv6=True): if len(hostname) > 253 or len(hostname) == 0: return False - # Strip trailling period on hostname (if one exists) + # Strip trailing period on hostname (if one exists) if hostname[-1] == ".": hostname = hostname[:-1] @@ -217,9 +242,14 @@ def is_hostname(hostname, ipv4=True, ipv6=True): # - Hostnames can ony be comprised of alpha-numeric characters and the # hyphen (-) character. # - Hostnames can not start with the hyphen (-) character. + # - as a workaround for https://github.com/docker/compose/issues/229 to + # being able to address services in other stacks, we also allow + # underscores in hostnames (if flag is set accordingly) # - labels can not exceed 63 characters + # - allow single character alpha characters allowed = re.compile( - r'(?!-)[a-z0-9][a-z0-9-]{1,62}(?<!-)$', + r'^([a-z0-9][a-z0-9_-]{1,62}|[a-z_-])(?<![_-])$' if underscore else + r'^([a-z0-9][a-z0-9-]{1,62}|[a-z-])(?<!-)$', re.IGNORECASE, ) @@ -229,6 +259,119 @@ def is_hostname(hostname, ipv4=True, ipv6=True): return hostname +def is_uuid(uuid): + """Determine if the specified entry is uuid v4 string + + Args: + address (str): The string you want to check. + + Returns: + bool: Returns False if the specified element is not a uuid otherwise + it returns True + """ + + try: + match = UUID4_RE.match(uuid) + + except TypeError: + # not parseable content + return False + + return True if match else False + + +def is_phone_no(phone, min_len=11): + """Determine if the specified entry is a phone number + + Args: + phone (str): The string you want to check. + min_len (int): Defines the smallest expected length of the phone + before it's to be considered invalid. By default + the phone number can't be any larger then 14 + + Returns: + bool: Returns False if the address specified is not a phone number + and a dictionary of the parsed phone number if it is as: + { + 'country': '1', + 'area': '800', + 'line': '1234567', + 'full': '18001234567', + 'pretty': '+1 800-123-4567', + } + + Non conventional numbers such as 411 would look like provided that + `min_len` is set to at least a 3: + { + 'country': '', + 'area': '', + 'line': '411', + 'full': '411', + 'pretty': '411', + } + + """ + + try: + if not IS_PHONE_NO.match(phone): + # not parseable content as it does not even conform closely to a + # phone number) + return False + + except TypeError: + return False + + # Tidy phone number up first + phone = re.sub(r'[^\d]+', '', phone) + if len(phone) > 14 or len(phone) < min_len: + # Invalid phone number + return False + + # Full phone number without any markup is as is now + full = phone + + # Break apart our phone number + line = phone[-7:] + phone = phone[:len(phone) - 7] if len(phone) > 7 else '' + + # the area code (if present) + area = phone[-3:] if phone else '' + + # The country code is the leftovers + country = phone[:len(phone) - 3] if len(phone) > 3 else '' + + # Prepare a nicely (consistently) formatted phone number + pretty = '' + + if country: + # The leftover is the country code + pretty += '+{} '.format(country) + + if area: + pretty += '{}-'.format(area) + + if len(line) >= 7: + pretty += '{}-{}'.format(line[:3], line[3:]) + + else: + pretty += line + + return { + # The line code (last 7 digits) + 'line': line, + # Area code + 'area': area, + # The country code (if identified) + 'country': country, + + # A nicely formatted phone no + 'pretty': pretty, + + # All digits in-line + 'full': full, + } + + def is_email(address): """Determine if the specified entry is an email address @@ -236,8 +379,17 @@ def is_email(address): address (str): The string you want to check. Returns: - bool: Returns True if the address specified is an email address - and False if it isn't. + bool: Returns False if the address specified is not an email address + and a dictionary of the parsed email if it is as: + { + 'name': 'Parse Name' + 'email': '[email protected]' + 'full_email': '[email protected]' + 'label': 'label' + 'user': 'user', + 'domain': 'domain.com' + } + """ try: @@ -318,10 +470,11 @@ def parse_qsd(qs): 'qsd': {}, # Detected Entries that start with + or - are additionally stored in - # these values (un-touched). The +/- however are stripped from their + # these values (un-touched). The :,+,- however are stripped from their # name before they are stored here. 'qsd+': {}, 'qsd-': {}, + 'qsd:': {}, } pairs = [s2 for s1 in qs.split('&') for s2 in s1.split(';')] @@ -361,6 +514,12 @@ def parse_qsd(qs): # Store content 'as-is' result['qsd-'][k.group('key')] = val + # Check for tokens that start with a colon symbol (:) + k = NOTIFY_CUSTOM_COLON_TOKENS.match(key) + if k is not None: + # Store content 'as-is' + result['qsd:'][k.group('key')] = val + return result @@ -418,11 +577,12 @@ def parse_url(url, default_schema='http', verify_host=True): # qsd = Query String Dictionary 'qsd': {}, - # Detected Entries that start with + or - are additionally stored in - # these values (un-touched). The +/- however are stripped from their - # name before they are stored here. + # Detected Entries that start with +, - or : are additionally stored in + # these values (un-touched). The +, -, and : however are stripped + # from their name before they are stored here. 'qsd+': {}, 'qsd-': {}, + 'qsd:': {}, } qsdata = '' @@ -534,10 +694,7 @@ def parse_url(url, default_schema='http', verify_host=True): def parse_bool(arg, default=False): """ - NZBGet uses 'yes' and 'no' as well as other strings such as 'on' or - 'off' etch to handle boolean operations from it's control interface. - - This method can just simplify checks to these variables. + Support string based boolean settings. If the content could not be parsed, then the default is returned. """ @@ -572,9 +729,46 @@ def parse_bool(arg, default=False): return bool(arg) +def parse_phone_no(*args, **kwargs): + """ + Takes a string containing phone numbers separated by comma's and/or spaces + and returns a list. + """ + + # for Python 2.7 support, store_unparsable is not in the url above + # as just parse_emails(*args, store_unparseable=True) since it is + # an invalid syntax. This is the workaround to be backards compatible: + store_unparseable = kwargs.get('store_unparseable', True) + + result = [] + for arg in args: + if isinstance(arg, six.string_types) and arg: + _result = PHONE_NO_DETECTION_RE.findall(arg) + if _result: + result += _result + + elif not _result and store_unparseable: + # we had content passed into us that was lost because it was + # so poorly formatted that it didn't even come close to + # meeting the regular expression we defined. We intentially + # keep it as part of our result set so that parsing done + # at a higher level can at least report this to the end user + # and hopefully give them some indication as to what they + # may have done wrong. + result += \ + [x for x in filter(bool, re.split(STRING_DELIMITERS, arg))] + + elif isinstance(arg, (set, list, tuple)): + # Use recursion to handle the list of phone numbers + result += parse_phone_no( + *arg, store_unparseable=store_unparseable) + + return result + + def parse_emails(*args, **kwargs): """ - Takes a string containing URLs separated by comma's and/or spaces and + Takes a string containing emails separated by comma's and/or spaces and returns a list. """ @@ -821,6 +1015,174 @@ def validate_regex(value, regex=r'[^\s]+', flags=re.I, strip=True, fmt=None): return value.strip() if strip else value +def cwe312_word(word, force=False, advanced=True, threshold=5): + """ + This function was written to help mask secure/private information that may + or may not be found within Apprise. The idea is to provide a presentable + word response that the user who prepared it would understand, yet not + reveal any private information for any potential intruder + + For more detail see CWE-312 @ + https://cwe.mitre.org/data/definitions/312.html + + The `force` is an optional argument used to keep the string formatting + consistent and in one place. If set, the content passed in is presumed + to be containing secret information and will be updated accordingly. + + If advanced is set to `True` then content is additionally checked for + upper/lower/ascii/numerical variances. If an obscurity threshold is + reached, then content is considered secret + """ + + class Variance(object): + """ + A Simple List of Possible Character Variances + """ + # An Upper Case Character (ABCDEF... etc) + ALPHA_UPPER = '+' + # An Lower Case Character (abcdef... etc) + ALPHA_LOWER = '-' + # A Special Character ($%^;... etc) + SPECIAL = 's' + # A Numerical Character (1234... etc) + NUMERIC = 'n' + + if not (isinstance(word, six.string_types) and word.strip()): + # not a password if it's not something we even support + return word + + # Formatting + word = word.strip() + if force: + # We're forcing the representation to be a secret + # We do this for consistency + return '{}...{}'.format(word[0:1], word[-1:]) + + elif len(word) > 1 and \ + not is_hostname(word, ipv4=True, ipv6=True, underscore=False): + # Verify if it is a hostname or not + return '{}...{}'.format(word[0:1], word[-1:]) + + elif len(word) >= 16: + # an IP will be 15 characters so we don't want to use a smaller + # value then 16 (e.g 101.102.103.104) + # we can assume very long words are passwords otherwise + return '{}...{}'.format(word[0:1], word[-1:]) + + if advanced: + # + # Mark word a secret based on it's obscurity + # + + # Our variances will increase depending on these variables: + last_variance = None + obscurity = 0 + + for c in word: + # Detect our variance + if c.isdigit(): + variance = Variance.NUMERIC + elif c.isalpha() and c.isupper(): + variance = Variance.ALPHA_UPPER + elif c.isalpha() and c.islower(): + variance = Variance.ALPHA_LOWER + else: + variance = Variance.SPECIAL + + if last_variance != variance or variance == Variance.SPECIAL: + obscurity += 1 + + if obscurity >= threshold: + return '{}...{}'.format(word[0:1], word[-1:]) + + last_variance = variance + + # Otherwise we're good; return our word + return word + + +def cwe312_url(url): + """ + This function was written to help mask secure/private information that may + or may not be found on an Apprise URL. The idea is to not disrupt the + structure of the previous URL too much, yet still protect the users + private information from being logged directly to screen. + + For more detail see CWE-312 @ + https://cwe.mitre.org/data/definitions/312.html + + For example, consider the URL: http://user:password@localhost/ + + When passed into this function, the return value would be: + http://user:****@localhost/ + + Since apprise allows you to put private information everywhere in it's + custom URLs, it uses this function to manipulate the content before + returning to any kind of logger. + + The idea is that the URL can still be interpreted by the person who + constructed them, but not to an intruder. + """ + # Parse our URL + results = parse_url(url) + if not results: + # Nothing was returned (invalid data was fed in); return our + # information as it was fed to us (without changing it) + return url + + # Update our URL with values + results['password'] = cwe312_word(results['password'], force=True) + if not results['schema'].startswith('http'): + results['user'] = cwe312_word(results['user']) + results['host'] = cwe312_word(results['host']) + + else: + results['host'] = cwe312_word(results['host'], advanced=False) + results['user'] = cwe312_word(results['user'], advanced=False) + + # Apply our full path scan in all cases + results['fullpath'] = '/' + \ + '/'.join([cwe312_word(x) + for x in re.split( + r'[\\/]+', + results['fullpath'].lstrip('/'))]) \ + if results['fullpath'] else '' + + # + # Now re-assemble our URL for display purposes + # + + # Determine Authentication + auth = '' + if results['user'] and results['password']: + auth = '{user}:{password}@'.format( + user=results['user'], + password=results['password'], + ) + elif results['user']: + auth = '{user}@'.format( + user=results['user'], + ) + + params = '' + if results['qsd']: + params = '?{}'.format( + "&".join(["{}={}".format(k, cwe312_word(v, force=( + k in ('password', 'secret', 'pass', 'token', 'key', + 'id', 'apikey', 'to')))) + for k, v in results['qsd'].items()])) + + return '{schema}://{auth}{hostname}{port}{fullpath}{params}'.format( + schema=results['schema'], + auth=auth, + # never encode hostname since we're expecting it to be a valid one + hostname=results['host'], + port='' if not results['port'] else ':{}'.format(results['port']), + fullpath=results['fullpath'] if results['fullpath'] else '', + params=params, + ) + + @contextlib.contextmanager def environ(*remove, **update): """ @@ -845,3 +1207,45 @@ def environ(*remove, **update): finally: # Restore our snapshot os.environ = env_orig.copy() + + +def apply_template(template, app_mode=TemplateType.RAW, **kwargs): + """ + Takes a template in a str format and applies all of the keywords + and their values to it. + + The app$mode is used to dictact any pre-processing that needs to take place + to the escaped string prior to it being placed. The idea here is for + elements to be placed in a JSON response for example should be escaped + early in their string format. + + The template must contain keywords wrapped in in double + squirly braces like {{keyword}}. These are matched to the respected + kwargs passed into this function. + + If there is no match found, content is not swapped. + + """ + + def _escape_raw(content): + # No escaping necessary + return content + + def _escape_json(content): + # remove surounding quotes + return json.dumps(content)[1:-1] + + # Our escape function + fn = _escape_json if app_mode == TemplateType.JSON else _escape_raw + + lookup = [re.escape(x) for x in kwargs.keys()] + + # Compile this into a list + mask_r = re.compile( + re.escape('{{') + r'\s*(' + '|'.join(lookup) + r')\s*' + + re.escape('}}'), re.IGNORECASE) + + # we index 2 characters off the head and 2 characters from the tail + # to drop the '{{' and '}}' surrounding our match so that we can + # re-index it back into our list + return mask_r.sub(lambda x: fn(kwargs[x.group()[2:-2].strip()]), template) diff --git a/libs/version.txt b/libs/version.txt index 43dd37aa1..5e419434b 100644 --- a/libs/version.txt +++ b/libs/version.txt @@ -1,4 +1,4 @@ -apprise=0.8.8 +apprise=0.9.6 apscheduler=3.8.0 babelfish=0.6.0 backports.functools-lru-cache=1.5 |