diff --git a/libs/apprise/Apprise.py b/libs/apprise/Apprise.py index ee199c4b1..31bd2888e 100644 --- a/libs/apprise/Apprise.py +++ b/libs/apprise/Apprise.py @@ -30,14 +30,15 @@ from markdown import markdown from itertools import chain from .common import NotifyType from .common import NotifyFormat +from .common import MATCH_ALL_TAG from .utils import is_exclusive_match from .utils import parse_list from .utils import split_urls -from .utils import GET_SCHEMA_RE from .logger import logger from .AppriseAsset import AppriseAsset from .AppriseConfig import AppriseConfig +from .AppriseAttachment import AppriseAttachment from .AppriseLocale import AppriseLocale from .config.ConfigBase import ConfigBase from .plugins.NotifyBase import NotifyBase @@ -107,38 +108,8 @@ class Apprise(object): results = None if isinstance(url, six.string_types): - # swap hash (#) tag values with their html version - _url = url.replace('/#', '/%23') - - # 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: - logger.error( - 'Unparseable schema:// found in URL {}.'.format(url)) - return None - - # Ensure our schema is always in lower case - schema = schema.group('schema').lower() - - # Some basic validation - if schema not in plugins.SCHEMA_MAP: - # Give the user the benefit of the doubt that the user may be - # using one of the URLs provided to them by their notification - # service. Before we fail for good, just scan all the plugins - # that support he native_url() parse function - results = \ - next((r['plugin'].parse_native_url(_url) - for r in plugins.MODULE_MAP.values() - if r['plugin'].parse_native_url(_url) is not None), - None) - - else: - # Parse our url details of the server object as dictionary - # containing all of the information parsed from our URL - results = plugins.SCHEMA_MAP[schema].parse_url(_url) - + # Acquire our url tokens + results = plugins.url_to_dict(url) if results is None: # Failed to parse the server URL logger.error('Unparseable URL {}.'.format(url)) @@ -273,30 +244,12 @@ class Apprise(object): """ self.servers[:] = [] - def notify(self, body, title='', notify_type=NotifyType.INFO, - body_format=None, tag=None): + def find(self, tag=MATCH_ALL_TAG): """ - Send a notification to all of the plugins previously loaded. - - If the body_format specified is NotifyFormat.MARKDOWN, it will - be converted to HTML if the Notification type expects this. - - if the tag is specified (either a string or a set/list/tuple - of strings), then only the notifications flagged with that - tagged value are notified. By default all added services - are notified (tag=None) + Returns an list of all servers matching against the tag specified. """ - # Initialize our return result - status = len(self) > 0 - - if not (title or body): - return False - - # Tracks conversions - conversion_map = dict() - # Build our tag setup # - top level entries are treated as an 'or' # - second level (or more) entries are treated as 'and' @@ -319,79 +272,135 @@ class Apprise(object): for server in servers: # Apply our tag matching based on our defined logic - if tag is not None and not is_exclusive_match( - logic=tag, data=server.tags): - continue - - # If our code reaches here, we either did not define a tag (it - # was set to None), or we did define a tag and the logic above - # determined we need to notify the service it's associated with - if server.notify_format not in conversion_map: - if body_format == NotifyFormat.MARKDOWN and \ - server.notify_format == NotifyFormat.HTML: - - # Apply Markdown - conversion_map[server.notify_format] = markdown(body) - - elif body_format == NotifyFormat.TEXT and \ - server.notify_format == NotifyFormat.HTML: - - # Basic TEXT to HTML format map; supports keys only - re_map = { - # Support Ampersand - r'&': '&', - - # Spaces to   for formatting purposes since - # multiple spaces are treated as one an this may - # not be the callers intention - r' ': ' ', - - # Tab support - r'\t': '   ', - - # Greater than and Less than Characters - r'>': '>', - r'<': '<', - } - - # Compile our map - re_table = re.compile( - r'(' + '|'.join( - map(re.escape, re_map.keys())) + r')', - re.IGNORECASE, - ) - - # Execute our map against our body in addition to - # swapping out new lines and replacing them with
- conversion_map[server.notify_format] = \ - re.sub(r'\r*\n', '
\r\n', - re_table.sub( - lambda x: re_map[x.group()], body)) - - else: - # Store entry directly - conversion_map[server.notify_format] = body - - try: - # Send notification - if not server.notify( - body=conversion_map[server.notify_format], - title=title, - notify_type=notify_type): - - # Toggle our return status flag - status = False - - except TypeError: - # These our our internally thrown notifications - status = False + if is_exclusive_match( + logic=tag, data=server.tags, match_all=MATCH_ALL_TAG): + yield server + return + + def notify(self, body, title='', notify_type=NotifyType.INFO, + body_format=None, tag=MATCH_ALL_TAG, attach=None): + """ + Send a notification to all of the plugins previously loaded. + + If the body_format specified is NotifyFormat.MARKDOWN, it will + be converted to HTML if the Notification type expects this. + + if the tag is specified (either a string or a set/list/tuple + of strings), then only the notifications flagged with that + tagged value are notified. By default all added services + are notified (tag=MATCH_ALL_TAG) + + This function returns True if all notifications were successfully + sent, False if even just one of them fails, and None if no + notifications were sent at all as a result of tag filtering and/or + simply having empty configuration files that were read. + + Attach can contain a list of attachment URLs. attach can also be + represented by a an AttachBase() (or list of) object(s). This + identifies the products you wish to notify + """ + + if len(self) == 0: + # Nothing to notify + 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("Notification Exception") + # Initialize our return result which only turns to True if we send + # at least one valid notification + status = None + + if not (title or body): + return False + + # 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 + + # 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 + if server.notify_format not in conversion_map: + if body_format == NotifyFormat.MARKDOWN and \ + server.notify_format == NotifyFormat.HTML: + + # Apply Markdown + conversion_map[server.notify_format] = markdown(body) + + elif body_format == NotifyFormat.TEXT and \ + server.notify_format == NotifyFormat.HTML: + + # Basic TEXT to HTML format map; supports keys only + re_map = { + # Support Ampersand + r'&': '&', + + # Spaces to   for formatting purposes since + # multiple spaces are treated as one an this may + # not be the callers intention + r' ': ' ', + + # Tab support + r'\t': '   ', + + # Greater than and Less than Characters + r'>': '>', + r'<': '<', + } + + # Compile our map + re_table = re.compile( + r'(' + '|'.join( + map(re.escape, re_map.keys())) + r')', + re.IGNORECASE, + ) + + # Execute our map against our body in addition to + # swapping out new lines and replacing them with
+ conversion_map[server.notify_format] = \ + re.sub(r'\r*\n', '
\r\n', + re_table.sub( + lambda x: re_map[x.group()], body)) + + else: + # Store entry directly + conversion_map[server.notify_format] = body + + 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 + return status def details(self, lang=None): @@ -519,6 +528,20 @@ class Apprise(object): # If we reach here, then we indexed out of range raise IndexError('list index out of range') + def __bool__(self): + """ + Allows the Apprise object to be wrapped in an Python 3.x based 'if + statement'. True is returned if at least one service has been loaded. + """ + return len(self) > 0 + + def __nonzero__(self): + """ + Allows the Apprise object to be wrapped in an Python 2.x based 'if + statement'. True is returned if at least one service has been loaded. + """ + return len(self) > 0 + def __iter__(self): """ Returns an iterator to each of our servers loaded. This includes those diff --git a/libs/apprise/AppriseConfig.py b/libs/apprise/AppriseConfig.py index a07ef4b44..95070012a 100644 --- a/libs/apprise/AppriseConfig.py +++ b/libs/apprise/AppriseConfig.py @@ -30,6 +30,7 @@ from . import ConfigBase from . import URLBase from .AppriseAsset import AppriseAsset +from .common import MATCH_ALL_TAG from .utils import GET_SCHEMA_RE from .utils import parse_list from .utils import is_exclusive_match @@ -55,8 +56,19 @@ class AppriseConfig(object): If no path is specified then a default list is used. - If cache is set to True, then after the data is loaded, it's cached - within this object so it isn't retrieved again later. + By default we cache our responses so that subsiquent calls does not + cause the content to be retrieved again. Setting this to False does + mean more then one call can be made to retrieve the (same) data. This + method can be somewhat inefficient if disabled and you're set up to + make remote calls. Only disable caching if you understand the + consequences. + + You can alternatively set the cache value to an int identifying the + number of seconds the previously retrieved can exist for before it + should be considered expired. + + It's also worth nothing that the cache value is only set to elements + that are not already of subclass ConfigBase() """ # Initialize a server list of URLs @@ -66,24 +78,43 @@ class AppriseConfig(object): self.asset = \ asset if isinstance(asset, AppriseAsset) else AppriseAsset() + # Set our cache flag + self.cache = cache + if paths is not None: # Store our path(s) self.add(paths) return - def add(self, configs, asset=None, tag=None): + def add(self, configs, asset=None, tag=None, cache=True): """ Adds one or more config URLs into our list. You can override the global asset if you wish by including it with the config(s) that you add. + By default we cache our responses so that subsiquent calls does not + cause the content to be retrieved again. Setting this to False does + mean more then one call can be made to retrieve the (same) data. This + method can be somewhat inefficient if disabled and you're set up to + make remote calls. Only disable caching if you understand the + consequences. + + You can alternatively set the cache value to an int identifying the + number of seconds the previously retrieved can exist for before it + should be considered expired. + + It's also worth nothing that the cache value is only set to elements + that are not already of subclass ConfigBase() """ # Initialize our return status return_status = True + # Initialize our default cache value + cache = cache if cache is not None else self.cache + if isinstance(asset, AppriseAsset): # prepare default asset asset = self.asset @@ -103,7 +134,7 @@ class AppriseConfig(object): 'specified.'.format(type(configs))) return False - # Iterate over our + # Iterate over our configuration for _config in configs: if isinstance(_config, ConfigBase): @@ -122,7 +153,8 @@ class AppriseConfig(object): # Instantiate ourselves an object, this function throws or # returns None if it fails - instance = AppriseConfig.instantiate(_config, asset=asset, tag=tag) + instance = AppriseConfig.instantiate( + _config, asset=asset, tag=tag, cache=cache) if not isinstance(instance, ConfigBase): return_status = False continue @@ -133,7 +165,7 @@ class AppriseConfig(object): # Return our status return return_status - def servers(self, tag=None, cache=True): + def servers(self, tag=MATCH_ALL_TAG, *args, **kwargs): """ Returns all of our servers dynamically build based on parsed configuration. @@ -160,21 +192,20 @@ class AppriseConfig(object): for entry in self.configs: # Apply our tag matching based on our defined logic - if tag is not None and not is_exclusive_match( - logic=tag, data=entry.tags): - continue - - # Build ourselves a list of services dynamically and return the - # as a list - response.extend(entry.servers(cache=cache)) + if is_exclusive_match( + logic=tag, data=entry.tags, match_all=MATCH_ALL_TAG): + # Build ourselves a list of services dynamically and return the + # as a list + response.extend(entry.servers()) return response @staticmethod - def instantiate(url, asset=None, tag=None, suppress_exceptions=True): + def instantiate(url, asset=None, tag=None, cache=None, + suppress_exceptions=True): """ Returns the instance of a instantiated configuration plugin based on - the provided Server URL. If the url fails to be parsed, then None + the provided Config URL. If the url fails to be parsed, then None is returned. """ @@ -211,6 +242,10 @@ class AppriseConfig(object): results['asset'] = \ asset if isinstance(asset, AppriseAsset) else AppriseAsset() + if cache is not None: + # Force an over-ride of the cache value to what we have specified + results['cache'] = cache + if suppress_exceptions: try: # Attempt to create an instance of our plugin using the parsed @@ -262,10 +297,11 @@ class AppriseConfig(object): # If we reach here, then we indexed out of range raise IndexError('list index out of range') - def pop(self, index): + def pop(self, index=-1): """ - Removes an indexed Apprise Configuration from the stack and - returns it. + Removes an indexed Apprise Configuration from the stack and returns it. + + By default, the last element is removed from the list """ # Remove our entry return self.configs.pop(index) @@ -276,6 +312,20 @@ class AppriseConfig(object): """ return self.configs[index] + def __bool__(self): + """ + Allows the Apprise object to be wrapped in an Python 3.x based 'if + statement'. True is returned if at least one service has been loaded. + """ + return True if self.configs else False + + def __nonzero__(self): + """ + Allows the Apprise object to be wrapped in an Python 2.x based 'if + statement'. True is returned if at least one service has been loaded. + """ + return True if self.configs else False + def __iter__(self): """ Returns an iterator to our config list diff --git a/libs/apprise/URLBase.py b/libs/apprise/URLBase.py index af5e67d5b..4d62b82cd 100644 --- a/libs/apprise/URLBase.py +++ b/libs/apprise/URLBase.py @@ -50,6 +50,21 @@ from .utils import parse_list # Used to break a path list into parts PATHSPLIT_LIST_DELIM = re.compile(r'[ \t\r\n,\\/]+') + +class PrivacyMode(object): + # Defines different privacy modes strings can be printed as + # Astrisk sets 4 of them: e.g. **** + # This is used for passwords + Secret = '*' + + # Outer takes the first and last character displaying them with + # 3 dots between. Hence, 'i-am-a-token' would become 'i...n' + Outer = 'o' + + # Displays the last four characters + Tail = 't' + + # Define the HTML Lookup Table HTML_LOOKUP = { 400: 'Bad Request - Unsupported Parameters.', @@ -183,7 +198,7 @@ class URLBase(object): self._last_io_datetime = datetime.now() return - def url(self): + def url(self, privacy=False, *args, **kwargs): """ Assembles the URL associated with the notification based on the arguments provied. @@ -204,6 +219,12 @@ class URLBase(object): # return any match return tags in self.tags + def __str__(self): + """ + Returns the url path + """ + return self.url(privacy=True) + @staticmethod def escape_html(html, convert_new_lines=False, whitespace=True): """ @@ -302,6 +323,44 @@ class URLBase(object): # Python v2.7 return _quote(content, safe=safe) + @staticmethod + def pprint(content, privacy=True, mode=PrivacyMode.Outer, + # privacy print; quoting is ignored when privacy is set to True + quote=True, safe='/', encoding=None, errors=None): + """ + Privacy Print is used to mainpulate the string before passing it into + part of the URL. It is used to mask/hide private details such as + tokens, passwords, apikeys, etc from on-lookers. If the privacy=False + is set, then the quote variable is the next flag checked. + + Quoting is never done if the privacy flag is set to true to avoid + skewing the expected output. + """ + + if not privacy: + if quote: + # Return quoted string if specified to do so + return URLBase.quote( + content, safe=safe, encoding=encoding, errors=errors) + + # Return content 'as-is' + return content + + if mode is PrivacyMode.Secret: + # Return 4 Asterisks + return '****' + + if not isinstance(content, six.string_types) or not content: + # Nothing more to do + return '' + + if mode is PrivacyMode.Tail: + # Return the trailing 4 characters + return '...{}'.format(content[-4:]) + + # Default mode is Outer Mode + return '{}...{}'.format(content[0:1], content[-1:]) + @staticmethod def urlencode(query, doseq=False, safe='', encoding=None, errors=None): """Convert a mapping object or a sequence of two-element tuples diff --git a/libs/apprise/__init__.py b/libs/apprise/__init__.py index 055a2369e..0b055d7fe 100644 --- a/libs/apprise/__init__.py +++ b/libs/apprise/__init__.py @@ -24,7 +24,7 @@ # THE SOFTWARE. __title__ = 'apprise' -__version__ = '0.7.9' +__version__ = '0.8.1' __author__ = 'Chris Caron' __license__ = 'MIT' __copywrite__ = 'Copyright (C) 2019 Chris Caron ' @@ -43,12 +43,15 @@ from .common import ConfigFormat from .common import CONFIG_FORMATS from .URLBase import URLBase +from .URLBase import PrivacyMode from .plugins.NotifyBase import NotifyBase from .config.ConfigBase import ConfigBase +from .attachment.AttachBase import AttachBase from .Apprise import Apprise from .AppriseAsset import AppriseAsset from .AppriseConfig import AppriseConfig +from .AppriseAttachment import AppriseAttachment # Set default logging handler to avoid "No handler found" warnings. import logging @@ -57,11 +60,11 @@ logging.getLogger(__name__).addHandler(NullHandler()) __all__ = [ # Core - 'Apprise', 'AppriseAsset', 'AppriseConfig', 'URLBase', 'NotifyBase', - 'ConfigBase', + 'Apprise', 'AppriseAsset', 'AppriseConfig', 'AppriseAttachment', 'URLBase', + 'NotifyBase', 'ConfigBase', 'AttachBase', # Reference 'NotifyType', 'NotifyImageSize', 'NotifyFormat', 'OverflowMode', 'NOTIFY_TYPES', 'NOTIFY_IMAGE_SIZES', 'NOTIFY_FORMATS', 'OVERFLOW_MODES', - 'ConfigFormat', 'CONFIG_FORMATS', + 'ConfigFormat', 'CONFIG_FORMATS', 'PrivacyMode', ] diff --git a/libs/apprise/cli.py b/libs/apprise/cli.py index e43f88a23..57e964a72 100644 --- a/libs/apprise/cli.py +++ b/libs/apprise/cli.py @@ -99,6 +99,9 @@ def print_version_msg(): @click.option('--config', '-c', default=None, type=str, multiple=True, metavar='CONFIG_URL', help='Specify one or more configuration locations.') +@click.option('--attach', '-a', default=None, type=str, multiple=True, + metavar='ATTACHMENT_URL', + help='Specify one or more configuration locations.') @click.option('--notification-type', '-n', default=NotifyType.INFO, type=str, metavar='TYPE', help='Specify the message type (default=info). Possible values' @@ -111,13 +114,17 @@ def print_version_msg(): 'which services to notify. Use multiple --tag (-g) entries to ' '"OR" the tags together and comma separated to "AND" them. ' 'If no tags are specified then all services are notified.') -@click.option('-v', '--verbose', count=True) -@click.option('-V', '--version', is_flag=True, +@click.option('--dry-run', '-d', is_flag=True, + help='Perform a trial run but only prints the notification ' + 'services to-be triggered to stdout. Notifications are never ' + 'sent using this mode.') +@click.option('--verbose', '-v', count=True) +@click.option('--version', '-V', is_flag=True, help='Display the apprise version and exit.') @click.argument('urls', nargs=-1, metavar='SERVER_URL [SERVER_URL2 [SERVER_URL3]]',) -def main(body, title, config, urls, notification_type, theme, tag, verbose, - version): +def main(body, title, config, attach, urls, notification_type, theme, tag, + dry_run, verbose, version): """ Send a notification to all of the specified servers identified by their URLs the content provided within the title, body and notification-type. @@ -184,16 +191,52 @@ def main(body, title, config, urls, notification_type, theme, tag, verbose, print_help_msg(main) sys.exit(1) - if body is None: - # if no body was specified, then read from STDIN - body = click.get_text_stream('stdin').read() - # each --tag entry comprises of a comma separated 'and' list # we or each of of the --tag and sets specified. tags = None if not tag else [parse_list(t) for t in tag] - # now print it out - if a.notify( - body=body, title=title, notify_type=notification_type, tag=tags): - sys.exit(0) - sys.exit(1) + if not dry_run: + if body is None: + logger.trace('No --body (-b) specified; reading from stdin') + # if no body was specified, then read from STDIN + body = click.get_text_stream('stdin').read() + + # now print it out + result = a.notify( + body=body, title=title, notify_type=notification_type, tag=tags, + attach=attach) + else: + # Number of rows to assume in the terminal. In future, maybe this can + # be detected and made dynamic. The actual row count is 80, but 5 + # characters are already reserved for the counter on the left + rows = 75 + + # Initialize our URL response; This is populated within the for/loop + # below; but plays a factor at the end when we need to determine if + # we iterated at least once in the loop. + url = None + + for idx, server in enumerate(a.find(tag=tags)): + url = server.url(privacy=True) + click.echo("{: 3d}. {}".format( + idx + 1, + url if len(url) <= rows else '{}...'.format(url[:rows - 3]))) + if server.tags: + click.echo("{} - {}".format(' ' * 5, ', '.join(server.tags))) + + # Initialize a default response of nothing matched, otherwise + # if we matched at least one entry, we can return True + result = None if url is None else True + + if result is None: + # There were no notifications set. This is a result of just having + # empty configuration files and/or being to restrictive when filtering + # by specific tag(s) + sys.exit(2) + + elif result is False: + # At least 1 notification service failed to send + sys.exit(1) + + # else: We're good! + sys.exit(0) diff --git a/libs/apprise/common.py b/libs/apprise/common.py index 8005cc19d..90c65744a 100644 --- a/libs/apprise/common.py +++ b/libs/apprise/common.py @@ -128,3 +128,7 @@ CONFIG_FORMATS = ( ConfigFormat.TEXT, ConfigFormat.YAML, ) + +# This is a reserved tag that is automatically assigned to every +# Notification Plugin +MATCH_ALL_TAG = 'all' diff --git a/libs/apprise/config/ConfigBase.py b/libs/apprise/config/ConfigBase.py index d5acc4af1..539d4c494 100644 --- a/libs/apprise/config/ConfigBase.py +++ b/libs/apprise/config/ConfigBase.py @@ -27,6 +27,7 @@ import os import re import six import yaml +import time from .. import plugins from ..AppriseAsset import AppriseAsset @@ -35,6 +36,7 @@ from ..common import ConfigFormat from ..common import CONFIG_FORMATS from ..utils import GET_SCHEMA_RE from ..utils import parse_list +from ..utils import parse_bool class ConfigBase(URLBase): @@ -58,16 +60,31 @@ class ConfigBase(URLBase): # anything else. 128KB (131072B) max_buffer_size = 131072 - def __init__(self, **kwargs): + def __init__(self, cache=True, **kwargs): """ Initialize some general logging and common server arguments that will keep things consistent when working with the configurations that inherit this class. + By default we cache our responses so that subsiquent calls does not + cause the content to be retrieved again. For local file references + this makes no difference at all. But for remote content, this does + mean more then one call can be made to retrieve the (same) data. This + method can be somewhat inefficient if disabled. Only disable caching + if you understand the consequences. + + You can alternatively set the cache value to an int identifying the + number of seconds the previously retrieved can exist for before it + should be considered expired. """ super(ConfigBase, self).__init__(**kwargs) + # Tracks the time the content was last retrieved on. This place a role + # for cases where we are not caching our response and are required to + # re-retrieve our settings. + self._cached_time = None + # Tracks previously loaded content for speed self._cached_servers = None @@ -86,20 +103,34 @@ class ConfigBase(URLBase): self.logger.warning(err) raise TypeError(err) + # Set our cache flag; it can be True or a (positive) integer + try: + self.cache = cache if isinstance(cache, bool) else int(cache) + if self.cache < 0: + err = 'A negative cache value ({}) was specified.'.format( + cache) + self.logger.warning(err) + raise TypeError(err) + + except (ValueError, TypeError): + err = 'An invalid cache value ({}) was specified.'.format(cache) + self.logger.warning(err) + raise TypeError(err) + return - def servers(self, asset=None, cache=True, **kwargs): + def servers(self, asset=None, **kwargs): """ Performs reads loaded configuration and returns all of the services that could be parsed and loaded. """ - if cache is True and isinstance(self._cached_servers, list): + if not self.expired(): # We already have cached results to return; use them return self._cached_servers - # Our response object + # Our cached response object self._cached_servers = list() # read() causes the child class to do whatever it takes for the @@ -107,8 +138,11 @@ class ConfigBase(URLBase): # None is returned if there was an error or simply no data content = self.read(**kwargs) if not isinstance(content, six.string_types): - # Nothing more to do - return list() + # Set the time our content was cached at + self._cached_time = time.time() + + # Nothing more to do; return our empty cache list + return self._cached_servers # Our Configuration format uses a default if one wasn't one detected # or enfored. @@ -129,6 +163,9 @@ class ConfigBase(URLBase): self.logger.warning('Failed to load configuration from {}'.format( self.url())) + # Set the time our content was cached at + self._cached_time = time.time() + return self._cached_servers def read(self): @@ -138,13 +175,35 @@ class ConfigBase(URLBase): """ return None + def expired(self): + """ + Simply returns True if the configuration should be considered + as expired or False if content should be retrieved. + """ + if isinstance(self._cached_servers, list) and self.cache: + # We have enough reason to look further into our cached content + # and verify it has not expired. + if self.cache is True: + # we have not expired, return False + return False + + # Verify our cache time to determine whether we will get our + # content again. + age_in_sec = time.time() - self._cached_time + if age_in_sec <= self.cache: + # We have not expired; return False + return False + + # If we reach here our configuration should be considered + # missing and/or expired. + return True + @staticmethod def parse_url(url, verify_host=True): """Parses the URL and returns it broken apart into a dictionary. This is very specific and customized for Apprise. - Args: url (str): The URL you want to fully parse. verify_host (:obj:`bool`, optional): a flag kept with the parsed @@ -177,6 +236,17 @@ class ConfigBase(URLBase): if 'encoding' in results['qsd']: results['encoding'] = results['qsd'].get('encoding') + # Our cache value + if 'cache' in results['qsd']: + # First try to get it's integer value + try: + results['cache'] = int(results['qsd']['cache']) + + except (ValueError, TypeError): + # No problem, it just isn't an integer; now treat it as a bool + # instead: + results['cache'] = parse_bool(results['qsd']['cache']) + return results @staticmethod @@ -236,35 +306,14 @@ class ConfigBase(URLBase): # otherwise. return list() - if result.group('comment') or not result.group('line'): - # Comment/empty line; do nothing - continue - # Store our url read in url = result.group('url') - - # swap hash (#) tag values with their html version - _url = url.replace('/#', '/%23') - - # 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) - - # Ensure our schema is always in lower case - schema = schema.group('schema').lower() - - # Some basic validation - if schema not in plugins.SCHEMA_MAP: - ConfigBase.logger.warning( - 'Unsupported schema {} on line {}.'.format( - schema, line)) + if not url: + # Comment/empty line; do nothing continue - # Parse our url details of the server object as dictionary - # containing all of the information parsed from our URL - results = plugins.SCHEMA_MAP[schema].parse_url(_url) - + # Acquire our url tokens + results = plugins.url_to_dict(url) if results is None: # Failed to parse the server URL ConfigBase.logger.warning( @@ -316,6 +365,7 @@ class ConfigBase(URLBase): Optionally associate an asset with the notification. """ + response = list() try: @@ -406,72 +456,69 @@ class ConfigBase(URLBase): results = list() if isinstance(url, six.string_types): - # We're just a simple URL string - - # swap hash (#) tag values with their html version - _url = url.replace('/#', '/%23') - - # 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) + # We're just a simple URL string... + schema = GET_SCHEMA_RE.match(url) if schema is None: + # Log invalid entries so that maintainer of config + # config file at least has something to take action + # with. ConfigBase.logger.warning( - 'Unsupported schema in urls entry #{}'.format(no + 1)) - continue - - # Ensure our schema is always in lower case - schema = schema.group('schema').lower() - - # Some basic validation - if schema not in plugins.SCHEMA_MAP: - ConfigBase.logger.warning( - 'Unsupported schema {} in urls entry #{}'.format( - schema, no + 1)) + 'Invalid URL {}, entry #{}'.format(url, no + 1)) continue - # Parse our url details of the server object as dictionary - # containing all of the information parsed from our URL - _results = plugins.SCHEMA_MAP[schema].parse_url(_url) + # We found a valid schema worthy of tracking; store it's + # details: + _results = plugins.url_to_dict(url) if _results is None: ConfigBase.logger.warning( - 'Unparseable {} based url; entry #{}'.format( - schema, no + 1)) + 'Unparseable URL {}, entry #{}'.format( + url, no + 1)) continue # add our results to our global set results.append(_results) elif isinstance(url, dict): - # We are a url string with additional unescaped options + # We are a url string with additional unescaped options. In + # this case we want to iterate over all of our options so we + # can at least tell the end user what entries were ignored + # due to errors + if six.PY2: - _url, tokens = next(url.iteritems()) + it = url.iteritems() else: # six.PY3 - _url, tokens = next(iter(url.items())) - - # swap hash (#) tag values with their html version - _url = _url.replace('/#', '/%23') - - # Get our schema - schema = GET_SCHEMA_RE.match(_url) - if schema is None: - ConfigBase.logger.warning( - 'Unsupported schema in urls entry #{}'.format(no + 1)) - continue - - # Ensure our schema is always in lower case - schema = schema.group('schema').lower() - - # Some basic validation - if schema not in plugins.SCHEMA_MAP: + it = iter(url.items()) + + # Track the URL to-load + _url = None + + # Track last acquired schema + schema = None + for key, tokens in it: + # Test our schema + _schema = GET_SCHEMA_RE.match(key) + if _schema is None: + # Log invalid entries so that maintainer of config + # config file at least has something to take action + # with. + ConfigBase.logger.warning( + 'Ignored entry {} found under urls, entry #{}' + .format(key, no + 1)) + continue + + # Store our URL and Schema Regex + _url = key + + # Store our schema + schema = _schema.group('schema').lower() + + if _url is None: + # the loop above failed to match anything ConfigBase.logger.warning( - 'Unsupported schema {} in urls entry #{}'.format( - schema, no + 1)) + 'Unsupported schema in urls, entry #{}'.format(no + 1)) continue - # Parse our url details of the server object as dictionary - # containing all of the information parsed from our URL - _results = plugins.SCHEMA_MAP[schema].parse_url(_url) + _results = plugins.url_to_dict(_url) if _results is None: # Setup dictionary _results = { @@ -479,7 +526,7 @@ class ConfigBase(URLBase): 'schema': schema, } - if tokens is not None: + if isinstance(tokens, (list, tuple, set)): # populate and/or override any results populated by # parse_url() for entries in tokens: @@ -565,15 +612,16 @@ class ConfigBase(URLBase): return response - def pop(self, index): + def pop(self, index=-1): """ - Removes an indexed Notification Service from the stack and - returns it. + Removes an indexed Notification Service from the stack and returns it. + + By default, the last element of the list is removed. """ if not isinstance(self._cached_servers, list): # Generate ourselves a list of content we can pull from - self.servers(cache=True) + self.servers() # Pop the element off of the stack return self._cached_servers.pop(index) @@ -585,7 +633,7 @@ class ConfigBase(URLBase): """ if not isinstance(self._cached_servers, list): # Generate ourselves a list of content we can pull from - self.servers(cache=True) + self.servers() return self._cached_servers[index] @@ -595,7 +643,7 @@ class ConfigBase(URLBase): """ if not isinstance(self._cached_servers, list): # Generate ourselves a list of content we can pull from - self.servers(cache=True) + self.servers() return iter(self._cached_servers) @@ -605,6 +653,28 @@ class ConfigBase(URLBase): """ if not isinstance(self._cached_servers, list): # Generate ourselves a list of content we can pull from - self.servers(cache=True) + self.servers() return len(self._cached_servers) + + def __bool__(self): + """ + Allows the Apprise object to be wrapped in an Python 3.x based 'if + statement'. True is returned if our content was downloaded correctly. + """ + if not isinstance(self._cached_servers, list): + # Generate ourselves a list of content we can pull from + self.servers() + + return True if self._cached_servers else False + + def __nonzero__(self): + """ + Allows the Apprise object to be wrapped in an Python 2.x based 'if + statement'. True is returned if our content was downloaded correctly. + """ + if not isinstance(self._cached_servers, list): + # Generate ourselves a list of content we can pull from + self.servers() + + return True if self._cached_servers else False diff --git a/libs/apprise/config/ConfigFile.py b/libs/apprise/config/ConfigFile.py index e9ab93bf1..917eea081 100644 --- a/libs/apprise/config/ConfigFile.py +++ b/libs/apprise/config/ConfigFile.py @@ -26,9 +26,9 @@ import re import io import os -from os.path import expanduser from .ConfigBase import ConfigBase from ..common import ConfigFormat +from ..AppriseLocale import gettext_lazy as _ class ConfigFile(ConfigBase): @@ -36,8 +36,8 @@ class ConfigFile(ConfigBase): A wrapper for File based configuration sources """ - # The default descriptive name associated with the Notification - service_name = 'Local File' + # The default descriptive name associated with the service + service_name = _('Local File') # The default protocol protocol = 'file' @@ -53,27 +53,35 @@ class ConfigFile(ConfigBase): super(ConfigFile, self).__init__(**kwargs) # Store our file path as it was set - self.path = path + self.path = os.path.expanduser(path) return - def url(self): + def url(self, privacy=False, *args, **kwargs): """ Returns the URL built dynamically based on specified arguments. """ + # Prepare our cache value + if isinstance(self.cache, bool) or not self.cache: + cache = 'yes' if self.cache else 'no' + + else: + cache = int(self.cache) + # Define any arguments set args = { 'encoding': self.encoding, + 'cache': cache, } if self.config_format: # A format was enforced; make sure it's passed back with the url args['format'] = self.config_format - return 'file://{path}?{args}'.format( + return 'file://{path}{args}'.format( path=self.quote(self.path), - args=self.urlencode(args), + args='?{}'.format(self.urlencode(args)) if args else '', ) def read(self, **kwargs): @@ -159,5 +167,5 @@ class ConfigFile(ConfigBase): if not match: return None - results['path'] = expanduser(ConfigFile.unquote(match.group('path'))) + results['path'] = ConfigFile.unquote(match.group('path')) return results diff --git a/libs/apprise/config/ConfigHTTP.py b/libs/apprise/config/ConfigHTTP.py index 6c3a3259c..299255d09 100644 --- a/libs/apprise/config/ConfigHTTP.py +++ b/libs/apprise/config/ConfigHTTP.py @@ -28,6 +28,8 @@ import six import requests from .ConfigBase import ConfigBase from ..common import ConfigFormat +from ..URLBase import PrivacyMode +from ..AppriseLocale import gettext_lazy as _ # Support YAML formats # text/yaml @@ -47,8 +49,8 @@ class ConfigHTTP(ConfigBase): A wrapper for HTTP based configuration sources """ - # The default descriptive name associated with the Notification - service_name = 'HTTP' + # The default descriptive name associated with the service + service_name = _('Web Based') # The default protocol protocol = 'http' @@ -89,14 +91,23 @@ class ConfigHTTP(ConfigBase): return - def url(self): + def url(self, privacy=False, *args, **kwargs): """ Returns the URL built dynamically based on specified arguments. """ + # Prepare our cache value + if isinstance(self.cache, bool) or not self.cache: + cache = 'yes' if self.cache else 'no' + + else: + cache = int(self.cache) + # Define any arguments set args = { + 'verify': 'yes' if self.verify_certificate else 'no', 'encoding': self.encoding, + 'cache': cache, } if self.config_format: @@ -111,7 +122,8 @@ class ConfigHTTP(ConfigBase): if self.user and self.password: auth = '{user}:{password}@'.format( user=self.quote(self.user, safe=''), - password=self.quote(self.password, safe=''), + password=self.pprint( + self.password, privacy, mode=PrivacyMode.Secret, safe=''), ) elif self.user: auth = '{user}@'.format( @@ -120,12 +132,13 @@ class ConfigHTTP(ConfigBase): default_port = 443 if self.secure else 80 - return '{schema}://{auth}{hostname}{port}/?{args}'.format( + return '{schema}://{auth}{hostname}{port}{fullpath}/?{args}'.format( schema=self.secure_protocol if self.secure else self.protocol, auth=auth, - hostname=self.host, + hostname=self.quote(self.host, safe=''), port='' if self.port is None or self.port == default_port else ':{}'.format(self.port), + fullpath=self.quote(self.fullpath, safe='/'), args=self.urlencode(args), ) @@ -167,61 +180,48 @@ class ConfigHTTP(ConfigBase): try: # Make our request - r = requests.post( - url, - headers=headers, - auth=auth, - verify=self.verify_certificate, - timeout=self.connection_timeout_sec, - stream=True, - ) - - if r.status_code != requests.codes.ok: - status_str = \ - ConfigBase.http_response_code_lookup(r.status_code) - self.logger.error( - 'Failed to get HTTP configuration: ' - '{}{} error={}.'.format( - status_str, - ',' if status_str else '', - r.status_code)) - - # Display payload for debug information only; Don't read any - # more than the first X bytes since we're potentially accessing - # content from untrusted servers. - if self.max_error_buffer_size > 0: - self.logger.debug( - 'Response Details:\r\n{}'.format( - r.content[0:self.max_error_buffer_size])) - - # Close out our connection if it exists to eliminate any - # potential inefficiencies with the Request connection pool as - # documented on their site when using the stream=True option. - r.close() - - # Return None (signifying a failure) - return None - - # Store our response - if self.max_buffer_size > 0 and \ - r.headers['Content-Length'] > self.max_buffer_size: - - # Provide warning of data truncation - self.logger.error( - 'HTTP config response exceeds maximum buffer length ' - '({}KB);'.format(int(self.max_buffer_size / 1024))) - - # Close out our connection if it exists to eliminate any - # potential inefficiencies with the Request connection pool as - # documented on their site when using the stream=True option. - r.close() - - # Return None - buffer execeeded - return None - - else: - # Store our result - response = r.content + with requests.post( + url, + headers=headers, + auth=auth, + verify=self.verify_certificate, + timeout=self.connection_timeout_sec, + stream=True) as r: + + # Handle Errors + r.raise_for_status() + + # Get our file-size (if known) + try: + file_size = int(r.headers.get('Content-Length', '0')) + except (TypeError, ValueError): + # Handle edge case where Content-Length is a bad value + file_size = 0 + + # Store our response + if self.max_buffer_size > 0 \ + and file_size > self.max_buffer_size: + + # Provide warning of data truncation + self.logger.error( + 'HTTP config response exceeds maximum buffer length ' + '({}KB);'.format(int(self.max_buffer_size / 1024))) + + # Return None - buffer execeeded + return None + + # Store our result (but no more than our buffer length) + response = r.content[:self.max_buffer_size + 1] + + # Verify that our content did not exceed the buffer size: + if len(response) > self.max_buffer_size: + # Provide warning of data truncation + self.logger.error( + 'HTTP config response exceeds maximum buffer length ' + '({}KB);'.format(int(self.max_buffer_size / 1024))) + + # Return None - buffer execeeded + return None # Detect config format based on mime if the format isn't # already enforced @@ -247,11 +247,6 @@ class ConfigHTTP(ConfigBase): # Return None (signifying a failure) return None - # Close out our connection if it exists to eliminate any potential - # inefficiencies with the Request connection pool as documented on - # their site when using the stream=True option. - r.close() - # Return our response object return response diff --git a/libs/apprise/config/__init__.py b/libs/apprise/config/__init__.py index f91bc9e38..5c3980318 100644 --- a/libs/apprise/config/__init__.py +++ b/libs/apprise/config/__init__.py @@ -43,7 +43,7 @@ def __load_matrix(path=abspath(dirname(__file__)), name='apprise.config'): skip over modules we simply don't have the dependencies for. """ - # Used for the detection of additional Notify Services objects + # Used for the detection of additional Configuration Services objects # The .py extension is optional as we support loading directories too module_re = re.compile(r'^(?PConfig[a-z0-9]+)(\.py)?$', re.I) diff --git a/libs/apprise/i18n/en/LC_MESSAGES/apprise.mo b/libs/apprise/i18n/en/LC_MESSAGES/apprise.mo deleted file mode 100644 index 0decd3509..000000000 Binary files a/libs/apprise/i18n/en/LC_MESSAGES/apprise.mo and /dev/null differ diff --git a/libs/apprise/plugins/NotifyBase.py b/libs/apprise/plugins/NotifyBase.py index c0183c9f5..7e963b0ce 100644 --- a/libs/apprise/plugins/NotifyBase.py +++ b/libs/apprise/plugins/NotifyBase.py @@ -33,6 +33,7 @@ from ..common import NOTIFY_FORMATS from ..common import OverflowMode from ..common import OVERFLOW_MODES from ..AppriseLocale import gettext_lazy as _ +from ..AppriseAttachment import AppriseAttachment class NotifyBase(URLBase): @@ -241,12 +242,21 @@ class NotifyBase(URLBase): ) def notify(self, body, title=None, notify_type=NotifyType.INFO, - overflow=None, **kwargs): + overflow=None, attach=None, **kwargs): """ Performs notification """ + # 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 + # Handle situations where the title is None title = '' if not title else title @@ -255,7 +265,7 @@ class NotifyBase(URLBase): overflow=overflow): # Send notification if not self.send(body=chunk['body'], title=chunk['title'], - notify_type=notify_type): + notify_type=notify_type, attach=attach): # Toggle our return status flag return False diff --git a/libs/apprise/plugins/NotifyBoxcar.py b/libs/apprise/plugins/NotifyBoxcar.py index 5c74f44d6..341c5098c 100644 --- a/libs/apprise/plugins/NotifyBoxcar.py +++ b/libs/apprise/plugins/NotifyBoxcar.py @@ -38,7 +38,9 @@ except ImportError: from urllib.parse import urlparse from .NotifyBase import NotifyBase +from ..URLBase import PrivacyMode from ..utils import parse_bool +from ..utils import validate_regex from ..common import NotifyType from ..common import NotifyImageSize from ..AppriseLocale import gettext_lazy as _ @@ -57,11 +59,6 @@ IS_TAG = re.compile(r'^[@](?P[A-Z0-9]{1,63})$', re.I) # this plugin supports it. IS_DEVICETOKEN = re.compile(r'^[A-Z0-9]{64}$', re.I) -# Both an access key and seret key are created and assigned to each project -# you create on the boxcar website -VALIDATE_ACCESS = re.compile(r'[A-Z0-9_-]{64}', re.I) -VALIDATE_SECRET = re.compile(r'[A-Z0-9_-]{64}', re.I) - # Used to break apart list of potential tags by their delimiter into a useable # list. TAGS_LIST_DELIM = re.compile(r'[ \t\r\n,\\/]+') @@ -104,30 +101,30 @@ class NotifyBoxcar(NotifyBase): 'access_key': { 'name': _('Access Key'), 'type': 'string', - 'regex': (r'[A-Z0-9_-]{64}', 'i'), 'private': True, 'required': True, + 'regex': (r'^[A-Z0-9_-]{64}$', 'i'), 'map_to': 'access', }, 'secret_key': { 'name': _('Secret Key'), 'type': 'string', - 'regex': (r'[A-Z0-9_-]{64}', 'i'), 'private': True, 'required': True, + 'regex': (r'^[A-Z0-9_-]{64}$', 'i'), 'map_to': 'secret', }, 'target_tag': { 'name': _('Target Tag ID'), 'type': 'string', 'prefix': '@', - 'regex': (r'[A-Z0-9]{1,63}', 'i'), + 'regex': (r'^[A-Z0-9]{1,63}$', 'i'), 'map_to': 'targets', }, 'target_device': { 'name': _('Target Device ID'), 'type': 'string', - 'regex': (r'[A-Z0-9]{64}', 'i'), + 'regex': (r'^[A-Z0-9]{64}$', 'i'), 'map_to': 'targets', }, 'targets': { @@ -162,33 +159,21 @@ class NotifyBoxcar(NotifyBase): # Initialize device_token list self.device_tokens = list() - try: - # Access Key (associated with project) - self.access = access.strip() - - except AttributeError: - msg = 'The specified access key is invalid.' - self.logger.warning(msg) - raise TypeError(msg) - - try: - # Secret Key (associated with project) - self.secret = secret.strip() - - except AttributeError: - msg = 'The specified secret key is invalid.' - self.logger.warning(msg) - raise TypeError(msg) - - if not VALIDATE_ACCESS.match(self.access): - msg = 'The access key specified ({}) is invalid.'\ - .format(self.access) + # Access Key (associated with project) + self.access = validate_regex( + access, *self.template_tokens['access_key']['regex']) + if not self.access: + msg = 'An invalid Boxcar Access Key ' \ + '({}) was specified.'.format(access) self.logger.warning(msg) raise TypeError(msg) - if not VALIDATE_SECRET.match(self.secret): - msg = 'The secret key specified ({}) is invalid.'\ - .format(self.secret) + # Secret Key (associated with project) + self.secret = validate_regex( + secret, *self.template_tokens['secret_key']['regex']) + if not self.secret: + msg = 'An invalid Boxcar Secret Key ' \ + '({}) was specified.'.format(secret) self.logger.warning(msg) raise TypeError(msg) @@ -227,7 +212,6 @@ class NotifyBoxcar(NotifyBase): """ Perform Boxcar Notification """ - headers = { 'User-Agent': self.app_id, 'Content-Type': 'application/json' @@ -330,7 +314,7 @@ class NotifyBoxcar(NotifyBase): return True - def url(self): + def url(self, privacy=False, *args, **kwargs): """ Returns the URL built dynamically based on specified arguments. """ @@ -343,10 +327,11 @@ class NotifyBoxcar(NotifyBase): 'verify': 'yes' if self.verify_certificate else 'no', } - return '{schema}://{access}/{secret}/{targets}/?{args}'.format( + return '{schema}://{access}/{secret}/{targets}?{args}'.format( schema=self.secure_protocol, - access=NotifyBoxcar.quote(self.access, safe=''), - secret=NotifyBoxcar.quote(self.secret, safe=''), + access=self.pprint(self.access, privacy, safe=''), + secret=self.pprint( + self.secret, privacy, mode=PrivacyMode.Secret, safe=''), targets='/'.join([ NotifyBoxcar.quote(x, safe='') for x in chain( self.tags, self.device_tokens) if x != DEFAULT_TAG]), diff --git a/libs/apprise/plugins/NotifyD7Networks.py b/libs/apprise/plugins/NotifyD7Networks.py index 1b7fcb5eb..d784f1cda 100644 --- a/libs/apprise/plugins/NotifyD7Networks.py +++ b/libs/apprise/plugins/NotifyD7Networks.py @@ -38,6 +38,7 @@ from json import dumps from json import loads from .NotifyBase import NotifyBase +from ..URLBase import PrivacyMode from ..common import NotifyType from ..utils import parse_list from ..utils import parse_bool @@ -130,7 +131,7 @@ class NotifyD7Networks(NotifyBase): 'name': _('Target Phone No'), 'type': 'string', 'prefix': '+', - 'regex': (r'[0-9\s)(+-]+', 'i'), + 'regex': (r'^[0-9\s)(+-]+$', 'i'), 'map_to': 'targets', }, 'targets': { @@ -226,6 +227,8 @@ class NotifyD7Networks(NotifyBase): self.logger.warning(msg) raise TypeError(msg) + return + def send(self, body, title='', notify_type=NotifyType.INFO, **kwargs): """ Depending on whether we are set to batch mode or single mode this @@ -315,11 +318,13 @@ class NotifyD7Networks(NotifyBase): json_response = loads(r.content) status_str = json_response.get('message', status_str) - except (AttributeError, ValueError): - # could not parse JSON response... just use the status - # we already have. + except (AttributeError, TypeError, ValueError): + # ValueError = r.content is Unparsable + # TypeError = r.content is None + # AttributeError = r is None - # AttributeError means r.content was None + # We could not parse JSON response. + # We will just use the status we already have. pass self.logger.warning( @@ -347,9 +352,13 @@ class NotifyD7Networks(NotifyBase): count = int(json_response.get( 'data', {}).get('messageCount', -1)) - except (AttributeError, ValueError, TypeError): - # could not parse JSON response... just assume - # that our delivery is okay for now + except (AttributeError, TypeError, ValueError): + # ValueError = r.content is Unparsable + # TypeError = r.content is None + # AttributeError = r is None + + # We could not parse JSON response. Assume that + # our delivery is okay for now. pass if count != len(self.targets): @@ -380,7 +389,7 @@ class NotifyD7Networks(NotifyBase): return not has_error - def url(self): + def url(self, privacy=False, *args, **kwargs): """ Returns the URL built dynamically based on specified arguments. """ @@ -402,7 +411,8 @@ class NotifyD7Networks(NotifyBase): return '{schema}://{user}:{password}@{targets}/?{args}'.format( schema=self.secure_protocol, user=NotifyD7Networks.quote(self.user, safe=''), - password=NotifyD7Networks.quote(self.password, safe=''), + password=self.pprint( + self.password, privacy, mode=PrivacyMode.Secret, safe=''), targets='/'.join( [NotifyD7Networks.quote(x, safe='') for x in self.targets]), args=NotifyD7Networks.urlencode(args)) diff --git a/libs/apprise/plugins/NotifyDBus.py b/libs/apprise/plugins/NotifyDBus.py index b1db496cc..37f2b256a 100644 --- a/libs/apprise/plugins/NotifyDBus.py +++ b/libs/apprise/plugins/NotifyDBus.py @@ -54,6 +54,7 @@ try: from dbus import Interface from dbus import Byte from dbus import ByteArray + from dbus import DBusException # # now we try to determine which mainloop(s) we can access @@ -88,7 +89,7 @@ try: from gi.repository import GdkPixbuf NOTIFY_DBUS_IMAGE_SUPPORT = True - except (ImportError, ValueError): + except (ImportError, ValueError, AttributeError): # No problem; this will get caught in outer try/catch # A ValueError will get thrown upon calling gi.require_version() if @@ -159,10 +160,6 @@ class NotifyDBus(NotifyBase): # content to display body_max_line_count = 10 - # 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 - # 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 @@ -240,6 +237,8 @@ class NotifyDBus(NotifyBase): # or not. self.include_image = include_image + return + def send(self, body, title='', notify_type=NotifyType.INFO, **kwargs): """ Perform DBus Notification @@ -251,7 +250,20 @@ class NotifyDBus(NotifyBase): return False # Acquire our session - session = SessionBus(mainloop=MAINLOOP_MAP[self.schema]) + try: + session = SessionBus(mainloop=MAINLOOP_MAP[self.schema]) + + except DBusException: + # Handle exception + self.logger.warning('Failed to send DBus notification.') + self.logger.exception('DBus Exception') + return False + + # If there is no title, but there is a body, swap the two to get rid + # of the weird whitespace + if not title: + title = body + body = '' # acquire our dbus object dbus_obj = session.get_object( @@ -332,7 +344,7 @@ class NotifyDBus(NotifyBase): return True - def url(self): + def url(self, privacy=False, *args, **kwargs): """ Returns the URL built dynamically based on specified arguments. """ @@ -362,7 +374,7 @@ class NotifyDBus(NotifyBase): args['y'] = str(self.y_axis) return '{schema}://_/?{args}'.format( - schema=self.protocol, + schema=self.schema, args=NotifyDBus.urlencode(args), ) diff --git a/libs/apprise/plugins/NotifyDiscord.py b/libs/apprise/plugins/NotifyDiscord.py index 6db65c8dd..af6bafd49 100644 --- a/libs/apprise/plugins/NotifyDiscord.py +++ b/libs/apprise/plugins/NotifyDiscord.py @@ -49,6 +49,7 @@ from ..common import NotifyImageSize from ..common import NotifyFormat from ..common import NotifyType from ..utils import parse_bool +from ..utils import validate_regex from ..AppriseLocale import gettext_lazy as _ @@ -144,20 +145,22 @@ class NotifyDiscord(NotifyBase): """ super(NotifyDiscord, self).__init__(**kwargs) - if not webhook_id: - msg = 'An invalid Client ID was specified.' + # Webhook ID (associated with project) + self.webhook_id = validate_regex(webhook_id) + if not self.webhook_id: + msg = 'An invalid Discord Webhook ID ' \ + '({}) was specified.'.format(webhook_id) self.logger.warning(msg) raise TypeError(msg) - if not webhook_token: - msg = 'An invalid Webhook Token was specified.' + # Webhook Token (associated with project) + self.webhook_token = validate_regex(webhook_token) + if not self.webhook_token: + msg = 'An invalid Discord Webhook Token ' \ + '({}) was specified.'.format(webhook_token) self.logger.warning(msg) raise TypeError(msg) - # Store our data - self.webhook_id = webhook_id - self.webhook_token = webhook_token - # Text To Speech self.tts = tts @@ -175,17 +178,12 @@ class NotifyDiscord(NotifyBase): return - def send(self, body, title='', notify_type=NotifyType.INFO, **kwargs): + def send(self, body, title='', notify_type=NotifyType.INFO, attach=None, + **kwargs): """ Perform Discord Notification """ - headers = { - 'User-Agent': self.app_id, - 'Content-Type': 'multipart/form-data', - } - - # Prepare JSON Object payload = { # Text-To-Speech 'tts': self.tts, @@ -255,6 +253,50 @@ class NotifyDiscord(NotifyBase): # Optionally override the default username of the webhook payload['username'] = self.user + if not self._send(payload): + # We failed to post our message + return False + + if attach: + # Update our payload; the idea is to preserve it's other detected + # and assigned values for re-use here too + payload.update({ + # Text-To-Speech + 'tts': False, + # Wait until the upload has posted itself before continuing + 'wait': True, + }) + + # Remove our text/title based content for attachment use + if 'embeds' in payload: + # Markdown + del payload['embeds'] + + if 'content' in payload: + # Markdown + del payload['content'] + + # Send our attachments + for attachment in attach: + self.logger.info( + 'Posting Discord Attachment {}'.format(attachment.name)) + if not self._send(payload, attach=attachment): + # We failed to post our message + return False + + # Otherwise return + return True + + def _send(self, payload, attach=None, **kwargs): + """ + Wrapper to the requests (post) object + """ + + # Our headers + headers = { + 'User-Agent': self.app_id, + } + # Construct Notify URL notify_url = '{0}/{1}/{2}'.format( self.notify_url, @@ -270,11 +312,22 @@ class NotifyDiscord(NotifyBase): # Always call throttle before any remote server i/o is made self.throttle() + # Our attachment path (if specified) + files = None try: + + # Open our attachment path if required: + if attach: + files = {'file': (attach.name, open(attach.path, 'rb'))} + + else: + headers['Content-Type'] = 'application/json; charset=utf-8' + r = requests.post( notify_url, - data=dumps(payload), + data=payload if files else dumps(payload), headers=headers, + files=files, verify=self.verify_certificate, ) if r.status_code not in ( @@ -285,8 +338,9 @@ class NotifyDiscord(NotifyBase): NotifyBase.http_response_code_lookup(r.status_code) self.logger.warning( - 'Failed to send Discord notification: ' + 'Failed to send {}to Discord notification: ' '{}{}error={}.'.format( + attach.name if attach else '', status_str, ', ' if status_str else '', r.status_code)) @@ -297,19 +351,32 @@ class NotifyDiscord(NotifyBase): return False else: - self.logger.info('Sent Discord notification.') + self.logger.info('Sent Discord {}.'.format( + 'attachment' if attach else 'notification')) except requests.RequestException as e: self.logger.warning( - 'A Connection error occured sending Discord ' - 'notification.' - ) + 'A Connection error occured posting {}to Discord.'.format( + attach.name if attach else '')) self.logger.debug('Socket Exception: %s' % str(e)) return False + except (OSError, IOError) as e: + self.logger.warning( + 'An I/O error occured while reading {}.'.format( + attach.name if attach else 'attachment')) + self.logger.debug('I/O Exception: %s' % str(e)) + return False + + finally: + # Close our file (if it's open) stored in the second element + # of our files tuple (index 1) + if files: + files['file'][1].close() + return True - def url(self): + def url(self, privacy=False, *args, **kwargs): """ Returns the URL built dynamically based on specified arguments. """ @@ -328,8 +395,8 @@ class NotifyDiscord(NotifyBase): return '{schema}://{webhook_id}/{webhook_token}/?{args}'.format( schema=self.secure_protocol, - webhook_id=NotifyDiscord.quote(self.webhook_id, safe=''), - webhook_token=NotifyDiscord.quote(self.webhook_token, safe=''), + webhook_id=self.pprint(self.webhook_id, privacy, safe=''), + webhook_token=self.pprint(self.webhook_token, privacy, safe=''), args=NotifyDiscord.urlencode(args), ) @@ -405,7 +472,7 @@ class NotifyDiscord(NotifyBase): r'^https?://discordapp\.com/api/webhooks/' r'(?P[0-9]+)/' r'(?P[A-Z0-9_-]+)/?' - r'(?P\?[.+])?$', url, re.I) + r'(?P\?.+)?$', url, re.I) if result: return NotifyDiscord.parse_url( @@ -427,8 +494,8 @@ class NotifyDiscord(NotifyBase): """ regex = re.compile( - r'^\s*#+\s*(?P[^#\n]+)([ \r\t\v#])?' - r'(?P([^ \r\t\v#].+?)(\n(?!\s#))|\s*$)', flags=re.S | re.M) + r'\s*#[# \t\v]*(?P[^\n]+)(\n|\s*$)' + r'\s*((?P[^#].+?)(?=\s*$|[\r\n]+\s*#))?', flags=re.S) common = regex.finditer(markdown) fields = list() @@ -436,8 +503,9 @@ class NotifyDiscord(NotifyBase): d = el.groupdict() fields.append({ - 'name': d.get('name', '').strip(), - 'value': '```md\n' + d.get('value', '').strip() + '\n```' + 'name': d.get('name', '').strip('# \r\n\t\v'), + 'value': '```md\n' + + (d.get('value').strip() if d.get('value') else '') + '\n```' }) return fields diff --git a/libs/apprise/plugins/NotifyEmail.py b/libs/apprise/plugins/NotifyEmail.py index 3430d3825..d903ca554 100644 --- a/libs/apprise/plugins/NotifyEmail.py +++ b/libs/apprise/plugins/NotifyEmail.py @@ -27,14 +27,19 @@ import re import six import smtplib from email.mime.text import MIMEText +from email.mime.application import MIMEApplication +from email.mime.multipart import MIMEMultipart + from socket import error as SocketError from datetime import datetime from .NotifyBase import NotifyBase +from ..URLBase import PrivacyMode from ..common import NotifyFormat from ..common import NotifyType from ..utils import is_email from ..utils import parse_list +from ..utils import GET_EMAIL_RE from ..AppriseLocale import gettext_lazy as _ @@ -195,6 +200,23 @@ EMAIL_TEMPLATES = ( }, ), + # 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 + ( + 'SendGrid', + re.compile( + r'^((?P