diff options
author | Louis Vézina <[email protected]> | 2019-11-24 20:15:30 -0500 |
---|---|---|
committer | Louis Vézina <[email protected]> | 2019-11-24 20:15:30 -0500 |
commit | 777913bd40e2229dab0e240c2de08071ae18d651 (patch) | |
tree | 80ad5fb00c0feff9d21fe077b9d76c6389735e76 /libs | |
parent | 19675820204dae14b52f1726a2ca315e9e592711 (diff) | |
download | bazarr-777913bd40e2229dab0e240c2de08071ae18d651.tar.gz bazarr-777913bd40e2229dab0e240c2de08071ae18d651.zip |
Upgrade Apprise to fix issue with Discord notification.
Diffstat (limited to 'libs')
59 files changed, 2532 insertions, 1958 deletions
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 <br/> - conversion_map[server.notify_format] = \ - re.sub(r'\r*\n', '<br/>\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 <br/> + conversion_map[server.notify_format] = \ + re.sub(r'\r*\n', '<br/>\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): """ @@ -303,6 +324,44 @@ class URLBase(object): 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 <[email protected]>' @@ -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.') [email protected]('--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.') [email protected]('-v', '--verbose', count=True) [email protected]('-V', '--version', is_flag=True, [email protected]('--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.') [email protected]('--verbose', '-v', count=True) [email protected]('--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'^(?P<name>Config[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 Binary files differdeleted file mode 100644 index 0decd3509..000000000 --- a/libs/apprise/i18n/en/LC_MESSAGES/apprise.mo +++ /dev/null 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<name>[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<webhook_id>[0-9]+)/' r'(?P<webhook_token>[A-Z0-9_-]+)/?' - r'(?P<args>\?[.+])?$', url, re.I) + r'(?P<args>\?.+)?$', url, re.I) if result: return NotifyDiscord.parse_url( @@ -427,8 +494,8 @@ class NotifyDiscord(NotifyBase): """ regex = re.compile( - r'^\s*#+\s*(?P<name>[^#\n]+)([ \r\t\v#])?' - r'(?P<value>([^ \r\t\v#].+?)(\n(?!\s#))|\s*$)', flags=re.S | re.M) + r'\s*#[# \t\v]*(?P<name>[^\n]+)(\n|\s*$)' + r'\s*((?P<value>[^#].+?)(?=\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<label>[^+]+)\+)?(?P<id>[^@]+)@' + r'(?P<domain>(\.smtp)?sendgrid\.(com|net))$', re.I), + { + 'port': 465, + 'smtp_host': 'smtp.sendgrid.net', + 'secure': True, + 'secure_mode': SecureMailMode.SSL, + 'login_type': (WebBaseLogin.USERID, ) + }, + ), + # Catch All ( 'Custom', @@ -303,6 +325,14 @@ class NotifyEmail(NotifyBase): 'name': _('SMTP Server'), 'type': 'string', }, + 'cc': { + 'name': _('Carbon Copy'), + 'type': 'list:string', + }, + 'bcc': { + 'name': _('Blind Carbon Copy'), + 'type': 'list:string', + }, 'mode': { 'name': _('Secure Mode'), 'type': 'choice:string', @@ -319,7 +349,8 @@ class NotifyEmail(NotifyBase): }) def __init__(self, timeout=15, smtp_host=None, from_name=None, - from_addr=None, secure_mode=None, targets=None, **kwargs): + from_addr=None, secure_mode=None, targets=None, cc=None, + bcc=None, **kwargs): """ Initialize Email Object @@ -346,6 +377,12 @@ class NotifyEmail(NotifyBase): # Acquire targets self.targets = parse_list(targets) + # Acquire Carbon Copies + self.cc = set() + + # Acquire Blind Carbon Copies + self.bcc = set() + # Now we want to construct the To and From email # addresses from the URL provided self.from_name = from_name @@ -382,6 +419,30 @@ class NotifyEmail(NotifyBase): self.logger.warning(msg) raise TypeError(msg) + # Validate recipients (cc:) and drop bad ones: + for recipient in parse_list(cc): + + if GET_EMAIL_RE.match(recipient): + self.cc.add(recipient) + continue + + self.logger.warning( + 'Dropped invalid Carbon Copy email ' + '({}) specified.'.format(recipient), + ) + + # Validate recipients (bcc:) and drop bad ones: + for recipient in parse_list(bcc): + + if GET_EMAIL_RE.match(recipient): + self.bcc.add(recipient) + continue + + self.logger.warning( + 'Dropped invalid Blind Carbon Copy email ' + '({}) specified.'.format(recipient), + ) + # Apply any defaults based on certain known configurations self.NotifyEmailDefaults() @@ -399,11 +460,17 @@ class NotifyEmail(NotifyBase): # over-riding any smarts to be applied return + # detect our email address using our user/host combo + from_addr = '{}@{}'.format( + re.split(r'[\s@]+', self.user)[0], + self.host, + ) + for i in range(len(EMAIL_TEMPLATES)): # pragma: no branch - self.logger.debug('Scanning %s against %s' % ( - self.from_addr, EMAIL_TEMPLATES[i][0] + self.logger.trace('Scanning %s against %s' % ( + from_addr, EMAIL_TEMPLATES[i][0] )) - match = EMAIL_TEMPLATES[i][1].match(self.from_addr) + match = EMAIL_TEMPLATES[i][1].match(from_addr) if match: self.logger.info( 'Applying %s Defaults' % @@ -445,7 +512,8 @@ class NotifyEmail(NotifyBase): break - def send(self, body, title='', notify_type=NotifyType.INFO, **kwargs): + def send(self, body, title='', notify_type=NotifyType.INFO, attach=None, + **kwargs): """ Perform Email Notification """ @@ -469,26 +537,73 @@ class NotifyEmail(NotifyBase): has_error = True continue + # Strip target out of cc list if in To or Bcc + cc = (self.cc - self.bcc - set([to_addr])) + # Strip target out of bcc list if in To + bcc = (self.bcc - set([to_addr])) + self.logger.debug( 'Email From: {} <{}>'.format(from_name, self.from_addr)) self.logger.debug('Email To: {}'.format(to_addr)) + if len(cc): + self.logger.debug('Email Cc: {}'.format(', '.join(cc))) + if len(bcc): + self.logger.debug('Email Bcc: {}'.format(', '.join(bcc))) self.logger.debug('Login ID: {}'.format(self.user)) self.logger.debug( 'Delivery: {}:{}'.format(self.smtp_host, self.port)) # Prepare Email Message if self.notify_format == NotifyFormat.HTML: - email = MIMEText(body, 'html') + content = MIMEText(body, 'html') else: - email = MIMEText(body, 'plain') - - email['Subject'] = title - email['From'] = '{} <{}>'.format(from_name, self.from_addr) - email['To'] = to_addr - email['Date'] = \ + content = MIMEText(body, 'plain') + + base = MIMEMultipart() if attach else content + base['Subject'] = title + base['From'] = '{} <{}>'.format(from_name, self.from_addr) + base['To'] = to_addr + base['Cc'] = ','.join(cc) + base['Date'] = \ datetime.utcnow().strftime("%a, %d %b %Y %H:%M:%S +0000") - email['X-Application'] = self.app_id + base['X-Application'] = self.app_id + + if attach: + # First attach our body to our content as the first element + base.attach(content) + + attach_error = False + + # Now store our attachments + for attachment in attach: + if not attachment: + # We could not load the attachment; take an early + # exit since this isn't what the end user wanted + + self.logger.warning( + 'The specified attachment could not be referenced:' + ' {}.'.format(attachment.url(privacy=True))) + + # Mark our failure + attach_error = True + break + + with open(attachment.path, "rb") as abody: + app = MIMEApplication( + abody.read(), attachment.mimetype) + + app.add_header( + 'Content-Disposition', + 'attachment; filename="{}"'.format( + attachment.name)) + + base.attach(app) + + if attach_error: + # Mark our error and quit early + has_error = True + break # bind the socket variable to the current namespace socket = None @@ -522,7 +637,9 @@ class NotifyEmail(NotifyBase): # Send the email socket.sendmail( - self.from_addr, to_addr, email.as_string()) + self.from_addr, + [to_addr] + list(cc) + list(bcc), + base.as_string()) self.logger.info( 'Sent Email notification to "{}".'.format(to_addr)) @@ -543,7 +660,7 @@ class NotifyEmail(NotifyBase): return not has_error - def url(self): + def url(self, privacy=False, *args, **kwargs): """ Returns the URL built dynamically based on specified arguments. """ @@ -561,6 +678,14 @@ class NotifyEmail(NotifyBase): 'verify': 'yes' if self.verify_certificate else 'no', } + if len(self.cc) > 0: + # Handle our Carbon Copy Addresses + args['cc'] = ','.join(self.cc) + + if len(self.bcc) > 0: + # Handle our Blind Carbon Copy Addresses + args['bcc'] = ','.join(self.bcc) + # pull email suffix from username (if present) user = self.user.split('@')[0] @@ -569,7 +694,8 @@ class NotifyEmail(NotifyBase): if self.user and self.password: auth = '{user}:{password}@'.format( user=NotifyEmail.quote(user, safe=''), - password=NotifyEmail.quote(self.password, safe=''), + password=self.pprint( + self.password, privacy, mode=PrivacyMode.Secret, safe=''), ) else: # user url @@ -592,7 +718,7 @@ class NotifyEmail(NotifyBase): hostname=NotifyEmail.quote(self.host, safe=''), port='' if self.port is None or self.port == default_port else ':{}'.format(self.port), - targets='' if has_targets else '/'.join( + targets='' if not has_targets else '/'.join( [NotifyEmail.quote(x, safe='') for x in self.targets]), args=NotifyEmail.urlencode(args), ) @@ -648,6 +774,16 @@ class NotifyEmail(NotifyBase): # Extract the secure mode to over-ride the default results['secure_mode'] = results['qsd']['mode'].lower() + # Handle Carbon Copy Addresses + if 'cc' in results['qsd'] and len(results['qsd']['cc']): + results['cc'] = \ + NotifyEmail.parse_list(results['qsd']['cc']) + + # Handle Blind Carbon Copy Addresses + if 'bcc' in results['qsd'] and len(results['qsd']['bcc']): + results['bcc'] = \ + NotifyEmail.parse_list(results['qsd']['bcc']) + results['from_addr'] = from_addr results['smtp_host'] = smtp_host diff --git a/libs/apprise/plugins/NotifyEmby.py b/libs/apprise/plugins/NotifyEmby.py index 82ac7da26..c792b49bd 100644 --- a/libs/apprise/plugins/NotifyEmby.py +++ b/libs/apprise/plugins/NotifyEmby.py @@ -35,6 +35,7 @@ from json import dumps from json import loads from .NotifyBase import NotifyBase +from ..URLBase import PrivacyMode from ..utils import parse_bool from ..common import NotifyType from .. import __version__ as VERSION @@ -138,7 +139,7 @@ class NotifyEmby(NotifyBase): if not self.user: # User was not specified - msg = 'No Username was specified.' + msg = 'No Emby username was specified.' self.logger.warning(msg) raise TypeError(msg) @@ -239,9 +240,12 @@ class NotifyEmby(NotifyBase): try: results = loads(r.content) - except ValueError: - # A string like '' would cause this; basicallly the content - # that was provided was not a JSON string. We can stop here + except (AttributeError, TypeError, ValueError): + # ValueError = r.content is Unparsable + # TypeError = r.content is None + # AttributeError = r is None + + # This is a problem; abort return False # Acquire our Access Token @@ -399,10 +403,12 @@ class NotifyEmby(NotifyBase): try: results = loads(r.content) - except ValueError: - # A string like '' would cause this; basicallly the content - # that was provided was not a JSON string. There is nothing - # more we can do at this point + except (AttributeError, TypeError, ValueError): + # ValueError = r.content is Unparsable + # TypeError = r.content is None + # AttributeError = r is None + + # We need to abort at this point return sessions for entry in results: @@ -581,7 +587,7 @@ class NotifyEmby(NotifyBase): return not has_error - def url(self): + def url(self, privacy=False, *args, **kwargs): """ Returns the URL built dynamically based on specified arguments. """ @@ -599,7 +605,8 @@ class NotifyEmby(NotifyBase): if self.user and self.password: auth = '{user}:{password}@'.format( user=NotifyEmby.quote(self.user, safe=''), - password=NotifyEmby.quote(self.password, safe=''), + password=self.pprint( + self.password, privacy, mode=PrivacyMode.Secret, safe=''), ) else: # self.user is set auth = '{user}@'.format( diff --git a/libs/apprise/plugins/NotifyFaast.py b/libs/apprise/plugins/NotifyFaast.py index 39df2c219..4c7b1ad70 100644 --- a/libs/apprise/plugins/NotifyFaast.py +++ b/libs/apprise/plugins/NotifyFaast.py @@ -91,6 +91,8 @@ class NotifyFaast(NotifyBase): # Associate an image with our post self.include_image = include_image + return + def send(self, body, title='', notify_type=NotifyType.INFO, **kwargs): """ Perform Faast Notification @@ -161,7 +163,7 @@ class NotifyFaast(NotifyBase): return True - def url(self): + def url(self, privacy=False, *args, **kwargs): """ Returns the URL built dynamically based on specified arguments. """ @@ -176,7 +178,7 @@ class NotifyFaast(NotifyBase): return '{schema}://{authtoken}/?{args}'.format( schema=self.protocol, - authtoken=NotifyFaast.quote(self.authtoken, safe=''), + authtoken=self.pprint(self.authtoken, privacy, safe=''), args=NotifyFaast.urlencode(args), ) diff --git a/libs/apprise/plugins/NotifyFlock.py b/libs/apprise/plugins/NotifyFlock.py index b3e65672a..4f751e011 100644 --- a/libs/apprise/plugins/NotifyFlock.py +++ b/libs/apprise/plugins/NotifyFlock.py @@ -47,6 +47,7 @@ from ..common import NotifyFormat from ..common import NotifyImageSize from ..utils import parse_list from ..utils import parse_bool +from ..utils import validate_regex from ..AppriseLocale import gettext_lazy as _ @@ -56,12 +57,8 @@ FLOCK_HTTP_ERROR_MAP = { } # Used to detect a channel/user -IS_CHANNEL_RE = re.compile(r'^(#|g:)(?P<id>[A-Z0-9_]{12})$', re.I) -IS_USER_RE = re.compile(r'^(@|u:)?(?P<id>[A-Z0-9_]{12})$', re.I) - -# Token required as part of the API request -# /134b8gh0-eba0-4fa9-ab9c-257ced0e8221 -IS_API_TOKEN = re.compile(r'^[a-z0-9-]{24}$', re.I) +IS_CHANNEL_RE = re.compile(r'^(#|g:)(?P<id>[A-Z0-9_]+)$', re.I) +IS_USER_RE = re.compile(r'^(@|u:)?(?P<id>[A-Z0-9_]+)$', re.I) class NotifyFlock(NotifyBase): @@ -103,7 +100,7 @@ class NotifyFlock(NotifyBase): 'token': { 'name': _('Access Key'), 'type': 'string', - 'regex': (r'[a-z0-9-]{24}', 'i'), + 'regex': (r'^[a-z0-9-]{24}$', 'i'), 'private': True, 'required': True, }, @@ -115,14 +112,14 @@ class NotifyFlock(NotifyBase): 'name': _('To User ID'), 'type': 'string', 'prefix': '@', - 'regex': (r'[A-Z0-9_]{12}', 'i'), + 'regex': (r'^[A-Z0-9_]{12}$', 'i'), 'map_to': 'targets', }, 'to_channel': { 'name': _('To Channel ID'), 'type': 'string', 'prefix': '#', - 'regex': (r'[A-Z0-9_]{12}', 'i'), + 'regex': (r'^[A-Z0-9_]{12}$', 'i'), 'map_to': 'targets', }, 'targets': { @@ -153,15 +150,18 @@ class NotifyFlock(NotifyBase): # Build ourselves a target list self.targets = list() - # Initialize our token object - self.token = token.strip() - - if not IS_API_TOKEN.match(self.token): - msg = 'The Flock API Token specified ({}) is invalid.'.format( - self.token) + self.token = validate_regex( + token, *self.template_tokens['token']['regex']) + if not self.token: + msg = 'An invalid Flock Access Key ' \ + '({}) was specified.'.format(token) self.logger.warning(msg) raise TypeError(msg) + # Track whether or not we want to send an image with our notification + # or not. + self.include_image = include_image + # Track any issues has_error = False @@ -183,15 +183,13 @@ class NotifyFlock(NotifyBase): self.logger.warning( 'Ignoring invalid target ({}) specified.'.format(target)) - if has_error and len(self.targets) == 0: + if has_error and not self.targets: # We have a bot token and no target(s) to message - msg = 'No targets found with specified Flock Bot Token.' + msg = 'No Flock targets to notify.' self.logger.warning(msg) raise TypeError(msg) - # Track whether or not we want to send an image with our notification - # or not. - self.include_image = include_image + return def send(self, body, title='', notify_type=NotifyType.INFO, **kwargs): """ @@ -305,7 +303,7 @@ class NotifyFlock(NotifyBase): return not has_error - def url(self): + def url(self, privacy=False, *args, **kwargs): """ Returns the URL built dynamically based on specified arguments. """ @@ -320,7 +318,7 @@ class NotifyFlock(NotifyBase): return '{schema}://{token}/{targets}?{args}'\ .format( schema=self.secure_protocol, - token=NotifyFlock.quote(self.token, safe=''), + token=self.pprint(self.token, privacy, safe=''), targets='/'.join( [NotifyFlock.quote(target, safe='') for target in self.targets]), @@ -365,7 +363,7 @@ class NotifyFlock(NotifyBase): result = re.match( r'^https?://api\.flock\.com/hooks/sendMessage/' r'(?P<token>[a-z0-9-]{24})/?' - r'(?P<args>\?[.+])?$', url, re.I) + r'(?P<args>\?.+)?$', url, re.I) if result: return NotifyFlock.parse_url( diff --git a/libs/apprise/plugins/NotifyGitter.py b/libs/apprise/plugins/NotifyGitter.py index 47f69a078..84a2322c6 100644 --- a/libs/apprise/plugins/NotifyGitter.py +++ b/libs/apprise/plugins/NotifyGitter.py @@ -50,14 +50,12 @@ from ..common import NotifyFormat from ..common import NotifyType from ..utils import parse_list from ..utils import parse_bool +from ..utils import validate_regex from ..AppriseLocale import gettext_lazy as _ # API Gitter URL GITTER_API_URL = 'https://api.gitter.im/v1' -# Used to validate your personal access token -VALIDATE_TOKEN = re.compile(r'^[a-z0-9]{40}$', re.I) - # Used to break path apart into list of targets TARGET_LIST_DELIM = re.compile(r'[ \t\r\n,\\/]+') @@ -112,9 +110,9 @@ class NotifyGitter(NotifyBase): 'token': { 'name': _('Token'), 'type': 'string', - 'regex': (r'[a-z0-9]{40}', 'i'), 'private': True, 'required': True, + 'regex': (r'^[a-z0-9]{40}$', 'i'), }, 'targets': { 'name': _('Rooms'), @@ -141,24 +139,21 @@ class NotifyGitter(NotifyBase): """ super(NotifyGitter, self).__init__(**kwargs) - try: - # The personal access token associated with the account - self.token = token.strip() - - except AttributeError: - # Token was None - msg = 'No API Token was specified.' - self.logger.warning(msg) - raise TypeError(msg) - - if not VALIDATE_TOKEN.match(self.token): - msg = 'The Personal Access Token specified ({}) is invalid.' \ - .format(token) + # Secret Key (associated with project) + self.token = validate_regex( + token, *self.template_tokens['token']['regex']) + if not self.token: + msg = 'An invalid Gitter API Token ' \ + '({}) was specified.'.format(token) self.logger.warning(msg) raise TypeError(msg) # Parse our targets self.targets = parse_list(targets) + if not self.targets: + msg = 'There are no valid Gitter targets to notify.' + self.logger.warning(msg) + raise TypeError(msg) # Used to track maping of rooms to their numeric id lookup for # messaging @@ -168,6 +163,8 @@ class NotifyGitter(NotifyBase): # or not. self.include_image = include_image + return + def send(self, body, title='', notify_type=NotifyType.INFO, **kwargs): """ Perform Gitter Notification @@ -183,8 +180,6 @@ class NotifyGitter(NotifyBase): if image_url: body = '![alt]({})\n{}'.format(image_url, body) - # Create a copy of the targets list - targets = list(self.targets) if self._room_mapping is None: # Populate our room mapping self._room_mapping = {} @@ -225,10 +220,8 @@ class NotifyGitter(NotifyBase): 'uri': entry['uri'], } - if len(targets) == 0: - # No targets specified - return False - + # Create a copy of the targets list + targets = list(self.targets) while len(targets): target = targets.pop(0).lower() @@ -340,9 +333,10 @@ class NotifyGitter(NotifyBase): try: content = loads(r.content) - except (TypeError, ValueError): + except (AttributeError, TypeError, ValueError): # ValueError = r.content is Unparsable # TypeError = r.content is None + # AttributeError = r is None content = {} try: @@ -367,7 +361,7 @@ class NotifyGitter(NotifyBase): return (True, content) - def url(self): + def url(self, privacy=False, *args, **kwargs): """ Returns the URL built dynamically based on specified arguments. """ @@ -382,7 +376,7 @@ class NotifyGitter(NotifyBase): return '{schema}://{token}/{targets}/?{args}'.format( schema=self.secure_protocol, - token=NotifyGitter.quote(self.token, safe=''), + token=self.pprint(self.token, privacy, safe=''), targets='/'.join( [NotifyGitter.quote(x, safe='') for x in self.targets]), args=NotifyGitter.urlencode(args)) diff --git a/libs/apprise/plugins/NotifyGnome.py b/libs/apprise/plugins/NotifyGnome.py index b985e203f..012c76fc5 100644 --- a/libs/apprise/plugins/NotifyGnome.py +++ b/libs/apprise/plugins/NotifyGnome.py @@ -49,7 +49,7 @@ try: # We're good to go! NOTIFY_GNOME_SUPPORT_ENABLED = True -except (ImportError, ValueError): +except (ImportError, ValueError, AttributeError): # No problem; we just simply can't support this plugin; we could # be in microsoft windows, or we just don't have the python-gobject # library available to us (or maybe one we don't support)? @@ -150,6 +150,8 @@ class NotifyGnome(NotifyBase): # or not. self.include_image = include_image + return + def send(self, body, title='', notify_type=NotifyType.INFO, **kwargs): """ Perform Gnome Notification @@ -201,7 +203,7 @@ class NotifyGnome(NotifyBase): return True - def url(self): + def url(self, privacy=False, *args, **kwargs): """ Returns the URL built dynamically based on specified arguments. """ diff --git a/libs/apprise/plugins/NotifyGotify.py b/libs/apprise/plugins/NotifyGotify.py index 33d34c560..954a0a867 100644 --- a/libs/apprise/plugins/NotifyGotify.py +++ b/libs/apprise/plugins/NotifyGotify.py @@ -31,12 +31,12 @@ # f2c2688f0b5e6a816bbcec768ca1c0de5af76b88/ADD_MESSAGE_EXAMPLES.md#python # API: https://gotify.net/docs/swagger-docs -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 _ @@ -121,9 +121,12 @@ class NotifyGotify(NotifyBase): """ super(NotifyGotify, self).__init__(**kwargs) - if not isinstance(token, six.string_types): - msg = 'An invalid Gotify token was specified.' - self.logger.warning('msg') + # Token (associated with project) + self.token = validate_regex(token) + if not self.token: + msg = 'An invalid Gotify Token ' \ + '({}) was specified.'.format(token) + self.logger.warning(msg) raise TypeError(msg) if priority not in GOTIFY_PRIORITIES: @@ -138,11 +141,6 @@ class NotifyGotify(NotifyBase): else: self.schema = 'http' - # Our access token does not get created until we first - # authenticate with our Gotify server. The same goes for the - # user id below. - self.token = token - return def send(self, body, title='', notify_type=NotifyType.INFO, **kwargs): @@ -223,7 +221,7 @@ class NotifyGotify(NotifyBase): return True - def url(self): + def url(self, privacy=False, *args, **kwargs): """ Returns the URL built dynamically based on specified arguments. """ @@ -243,7 +241,7 @@ class NotifyGotify(NotifyBase): hostname=NotifyGotify.quote(self.host, safe=''), port='' if self.port is None or self.port == default_port else ':{}'.format(self.port), - token=NotifyGotify.quote(self.token, safe=''), + token=self.pprint(self.token, privacy, safe=''), args=NotifyGotify.urlencode(args), ) diff --git a/libs/apprise/plugins/NotifyGrowl/__init__.py b/libs/apprise/plugins/NotifyGrowl/__init__.py index 2e8fa6a78..5fa36795a 100644 --- a/libs/apprise/plugins/NotifyGrowl/__init__.py +++ b/libs/apprise/plugins/NotifyGrowl/__init__.py @@ -26,6 +26,7 @@ from .gntp import notifier from .gntp import errors from ..NotifyBase import NotifyBase +from ...URLBase import PrivacyMode from ...common import NotifyImageSize from ...common import NotifyType from ...utils import parse_bool @@ -89,25 +90,31 @@ class NotifyGrowl(NotifyBase): default_port = 23053 # Define object templates + # Define object templates templates = ( - '{schema}://{apikey}', - '{schema}://{apikey}/{providerkey}', + '{schema}://{host}', + '{schema}://{host}:{port}', + '{schema}://{password}@{host}', + '{schema}://{password}@{host}:{port}', ) # Define our template tokens template_tokens = dict(NotifyBase.template_tokens, **{ - 'apikey': { - 'name': _('API Key'), + 'host': { + 'name': _('Hostname'), 'type': 'string', - 'private': True, 'required': True, - 'map_to': 'host', }, - 'providerkey': { - 'name': _('Provider Key'), + 'port': { + 'name': _('Port'), + 'type': 'int', + 'min': 1, + 'max': 65535, + }, + 'password': { + 'name': _('Password'), 'type': 'string', 'private': True, - 'map_to': 'fullpath', }, }) @@ -262,7 +269,7 @@ class NotifyGrowl(NotifyBase): return True - def url(self): + def url(self, privacy=False, *args, **kwargs): """ Returns the URL built dynamically based on specified arguments. """ @@ -291,7 +298,8 @@ class NotifyGrowl(NotifyBase): if self.user: # The growl password is stored in the user field auth = '{password}@'.format( - password=NotifyGrowl.quote(self.user, safe=''), + password=self.pprint( + self.user, privacy, mode=PrivacyMode.Secret, safe=''), ) return '{schema}://{auth}{hostname}{port}/?{args}'.format( @@ -316,7 +324,6 @@ class NotifyGrowl(NotifyBase): # We're done early as we couldn't load the results return results - # Apply our settings now version = None if 'version' in results['qsd'] and len(results['qsd']['version']): # Allow the user to specify the version of the protocol to use. diff --git a/libs/apprise/plugins/NotifyIFTTT.py b/libs/apprise/plugins/NotifyIFTTT.py index 85c913ad2..0b1d42c0d 100644 --- a/libs/apprise/plugins/NotifyIFTTT.py +++ b/libs/apprise/plugins/NotifyIFTTT.py @@ -46,6 +46,7 @@ from json import dumps from .NotifyBase import NotifyBase from ..common import NotifyType from ..utils import parse_list +from ..utils import validate_regex from ..AppriseLocale import gettext_lazy as _ @@ -148,22 +149,21 @@ class NotifyIFTTT(NotifyBase): """ super(NotifyIFTTT, self).__init__(**kwargs) - if not webhook_id: - msg = 'You must specify the Webhooks webhook_id.' + # Webhook ID (associated with project) + self.webhook_id = validate_regex(webhook_id) + if not self.webhook_id: + msg = 'An invalid IFTTT Webhook ID ' \ + '({}) was specified.'.format(webhook_id) self.logger.warning(msg) raise TypeError(msg) # Store our Events we wish to trigger self.events = parse_list(events) - if not self.events: msg = 'You must specify at least one event you wish to trigger on.' self.logger.warning(msg) raise TypeError(msg) - # Store our APIKey - self.webhook_id = webhook_id - # Tokens to include in post self.add_tokens = {} if add_tokens: @@ -285,7 +285,7 @@ class NotifyIFTTT(NotifyBase): return not has_error - def url(self): + def url(self, privacy=False, *args, **kwargs): """ Returns the URL built dynamically based on specified arguments. """ @@ -303,7 +303,7 @@ class NotifyIFTTT(NotifyBase): return '{schema}://{webhook_id}@{events}/?{args}'.format( schema=self.secure_protocol, - webhook_id=NotifyIFTTT.quote(self.webhook_id, safe=''), + webhook_id=self.pprint(self.webhook_id, privacy, safe=''), events='/'.join([NotifyIFTTT.quote(x, safe='') for x in self.events]), args=NotifyIFTTT.urlencode(args), @@ -356,7 +356,7 @@ class NotifyIFTTT(NotifyBase): r'^https?://maker\.ifttt\.com/use/' r'(?P<webhook_id>[A-Z0-9_-]+)' r'/?(?P<events>([A-Z0-9_-]+/?)+)?' - r'/?(?P<args>\?[.+])?$', url, re.I) + r'/?(?P<args>\?.+)?$', url, re.I) if result: return NotifyIFTTT.parse_url( diff --git a/libs/apprise/plugins/NotifyJSON.py b/libs/apprise/plugins/NotifyJSON.py index 97e7406b4..ad772ca8f 100644 --- a/libs/apprise/plugins/NotifyJSON.py +++ b/libs/apprise/plugins/NotifyJSON.py @@ -28,6 +28,7 @@ import requests from json import dumps from .NotifyBase import NotifyBase +from ..URLBase import PrivacyMode from ..common import NotifyImageSize from ..common import NotifyType from ..AppriseLocale import gettext_lazy as _ @@ -67,7 +68,9 @@ class NotifyJSON(NotifyBase): '{schema}://{user}:{password}@{host}:{port}', ) - # Define our tokens + # 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'), @@ -120,7 +123,7 @@ class NotifyJSON(NotifyBase): return - def url(self): + def url(self, privacy=False, *args, **kwargs): """ Returns the URL built dynamically based on specified arguments. """ @@ -140,7 +143,8 @@ class NotifyJSON(NotifyBase): if self.user and self.password: auth = '{user}:{password}@'.format( user=NotifyJSON.quote(self.user, safe=''), - password=NotifyJSON.quote(self.password, safe=''), + password=self.pprint( + self.password, privacy, mode=PrivacyMode.Secret, safe=''), ) elif self.user: auth = '{user}@'.format( @@ -149,12 +153,13 @@ class NotifyJSON(NotifyBase): 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=NotifyJSON.quote(self.host, safe=''), port='' if self.port is None or self.port == default_port else ':{}'.format(self.port), + fullpath=NotifyJSON.quote(self.fullpath, safe='/'), args=NotifyJSON.urlencode(args), ) diff --git a/libs/apprise/plugins/NotifyJoin.py b/libs/apprise/plugins/NotifyJoin.py index 864c7c162..76011d984 100644 --- a/libs/apprise/plugins/NotifyJoin.py +++ b/libs/apprise/plugins/NotifyJoin.py @@ -41,18 +41,16 @@ from ..common import NotifyImageSize 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 _ -# Token required as part of the API request -VALIDATE_APIKEY = re.compile(r'[a-z0-9]{32}', re.I) - # Extend HTTP Error Messages JOIN_HTTP_ERROR_MAP = { 401: 'Unauthorized - Invalid Token.', } # Used to detect a device -IS_DEVICE_RE = re.compile(r'([a-z0-9]{32})', re.I) +IS_DEVICE_RE = re.compile(r'^[a-z0-9]{32}$', re.I) # Used to detect a device IS_GROUP_RE = re.compile( @@ -64,6 +62,24 @@ IS_GROUP_RE = re.compile( JOIN_IMAGE_XY = NotifyImageSize.XY_72 +# Priorities +class JoinPriority(object): + LOW = -2 + MODERATE = -1 + NORMAL = 0 + HIGH = 1 + EMERGENCY = 2 + + +JOIN_PRIORITIES = ( + JoinPriority.LOW, + JoinPriority.MODERATE, + JoinPriority.NORMAL, + JoinPriority.HIGH, + JoinPriority.EMERGENCY, +) + + class NotifyJoin(NotifyBase): """ A wrapper for Join Notifications @@ -104,14 +120,14 @@ class NotifyJoin(NotifyBase): 'apikey': { 'name': _('API Key'), 'type': 'string', - 'regex': (r'[a-z0-9]{32}', 'i'), + 'regex': (r'^[a-z0-9]{32}$', 'i'), 'private': True, 'required': True, }, 'device': { 'name': _('Device ID'), 'type': 'string', - 'regex': (r'[a-z0-9]{32}', 'i'), + 'regex': (r'^[a-z0-9]{32}$', 'i'), 'map_to': 'targets', }, 'group': { @@ -136,36 +152,78 @@ class NotifyJoin(NotifyBase): 'default': False, 'map_to': 'include_image', }, + 'priority': { + 'name': _('Priority'), + 'type': 'choice:int', + 'values': JOIN_PRIORITIES, + 'default': JoinPriority.NORMAL, + }, 'to': { 'alias_of': 'targets', }, }) - def __init__(self, apikey, targets, include_image=True, **kwargs): + def __init__(self, apikey, targets=None, include_image=True, priority=None, + **kwargs): """ Initialize Join Object """ super(NotifyJoin, self).__init__(**kwargs) - if not VALIDATE_APIKEY.match(apikey.strip()): - msg = 'The JOIN API Token specified ({}) is invalid.'\ - .format(apikey) + # Track whether or not we want to send an image with our notification + # or not. + self.include_image = include_image + + # API Key (associated with project) + self.apikey = validate_regex( + apikey, *self.template_tokens['apikey']['regex']) + if not self.apikey: + msg = 'An invalid Join API Key ' \ + '({}) was specified.'.format(apikey) self.logger.warning(msg) raise TypeError(msg) - # The token associated with the account - self.apikey = apikey.strip() + # The Priority of the message + if priority not in JOIN_PRIORITIES: + self.priority = self.template_args['priority']['default'] - # Parse devices specified - self.devices = parse_list(targets) + else: + self.priority = priority - if len(self.devices) == 0: - # Default to everyone - self.devices.append(self.default_join_group) + # Prepare a list of targets to store entries into + self.targets = list() - # Track whether or not we want to send an image with our notification - # or not. - self.include_image = include_image + # Prepare a parsed list of targets + targets = parse_list(targets) + if len(targets) == 0: + # Default to everyone if our list was empty + self.targets.append(self.default_join_group) + return + + # If we reach here we have some targets to parse + while len(targets): + # Parse our targets + target = targets.pop(0) + group_re = IS_GROUP_RE.match(target) + if group_re: + self.targets.append( + 'group.{}'.format(group_re.group('name').lower())) + continue + + elif IS_DEVICE_RE.match(target): + self.targets.append(target) + continue + + self.logger.warning( + 'Ignoring invalid Join device/group "{}"'.format(target) + ) + + if not self.targets: + msg = 'No Join targets to notify.' + self.logger.warning(msg) + raise TypeError(msg) + + return def send(self, body, title='', notify_type=NotifyType.INFO, **kwargs): """ @@ -180,26 +238,17 @@ class NotifyJoin(NotifyBase): # error tracking (used for function return) has_error = False - # Create a copy of the devices list - devices = list(self.devices) - while len(devices): - device = devices.pop(0) - group_re = IS_GROUP_RE.match(device) - if group_re: - device = 'group.{}'.format(group_re.group('name').lower()) + # Capture a list of our targets to notify + targets = list(self.targets) - elif not IS_DEVICE_RE.match(device): - self.logger.warning( - 'Skipping specified invalid device/group "{}"' - .format(device) - ) - # Mark our failure - has_error = True - continue + while len(targets): + # Pop the first element off of our list + target = targets.pop(0) url_args = { 'apikey': self.apikey, - 'deviceId': device, + 'deviceId': target, + 'priority': str(self.priority), 'title': title, 'text': body, } @@ -242,7 +291,7 @@ class NotifyJoin(NotifyBase): self.logger.warning( 'Failed to send Join notification to {}: ' '{}{}error={}.'.format( - device, + target, status_str, ', ' if status_str else '', r.status_code)) @@ -255,12 +304,12 @@ class NotifyJoin(NotifyBase): continue else: - self.logger.info('Sent Join notification to %s.' % device) + self.logger.info('Sent Join notification to %s.' % target) except requests.RequestException as e: self.logger.warning( 'A Connection error occured sending Join:%s ' - 'notification.' % device + 'notification.' % target ) self.logger.debug('Socket Exception: %s' % str(e)) @@ -270,24 +319,34 @@ class NotifyJoin(NotifyBase): return not has_error - def url(self): + def url(self, privacy=False, *args, **kwargs): """ Returns the URL built dynamically based on specified arguments. """ + _map = { + JoinPriority.LOW: 'low', + JoinPriority.MODERATE: 'moderate', + JoinPriority.NORMAL: 'normal', + JoinPriority.HIGH: 'high', + JoinPriority.EMERGENCY: 'emergency', + } # Define any arguments set args = { 'format': self.notify_format, 'overflow': self.overflow_mode, + 'priority': + _map[self.template_args['priority']['default']] + if self.priority not in _map else _map[self.priority], 'image': 'yes' if self.include_image else 'no', 'verify': 'yes' if self.verify_certificate else 'no', } - return '{schema}://{apikey}/{devices}/?{args}'.format( + return '{schema}://{apikey}/{targets}/?{args}'.format( schema=self.secure_protocol, - apikey=NotifyJoin.quote(self.apikey, safe=''), - devices='/'.join([NotifyJoin.quote(x, safe='') - for x in self.devices]), + apikey=self.pprint(self.apikey, privacy, safe=''), + targets='/'.join([NotifyJoin.quote(x, safe='') + for x in self.targets]), args=NotifyJoin.urlencode(args)) @staticmethod @@ -310,6 +369,23 @@ class NotifyJoin(NotifyBase): # Unquote our API Key results['apikey'] = NotifyJoin.unquote(results['apikey']) + # Set our priority + if 'priority' in results['qsd'] and len(results['qsd']['priority']): + _map = { + 'l': JoinPriority.LOW, + 'm': JoinPriority.MODERATE, + 'n': JoinPriority.NORMAL, + 'h': JoinPriority.HIGH, + 'e': JoinPriority.EMERGENCY, + } + try: + results['priority'] = \ + _map[results['qsd']['priority'][0].lower()] + + except KeyError: + # No priority was set + pass + # Our Devices results['targets'] = list() if results['user']: diff --git a/libs/apprise/plugins/NotifyMSTeams.py b/libs/apprise/plugins/NotifyMSTeams.py index b75be7ded..2f0815345 100644 --- a/libs/apprise/plugins/NotifyMSTeams.py +++ b/libs/apprise/plugins/NotifyMSTeams.py @@ -69,24 +69,13 @@ from ..common import NotifyImageSize from ..common import NotifyType from ..common import NotifyFormat from ..utils import parse_bool +from ..utils import validate_regex 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}' -# Token required as part of the API request -# /AAAAAAAAA@AAAAAAAAA/........./......... -VALIDATE_TOKEN_A = re.compile(r'{}@{}'.format(UUID4_RE, UUID4_RE), re.I) - -# Token required as part of the API request -# /................../BBBBBBBBB/.......... -VALIDATE_TOKEN_B = re.compile(r'[A-Za-z0-9]{32}') - -# Token required as part of the API request -# /........./........./CCCCCCCCCCCCCCCCCCCCCCCC -VALIDATE_TOKEN_C = re.compile(UUID4_RE, re.I) - class NotifyMSTeams(NotifyBase): """ @@ -124,26 +113,32 @@ class NotifyMSTeams(NotifyBase): # Define our template tokens template_tokens = dict(NotifyBase.template_tokens, **{ + # Token required as part of the API request + # /AAAAAAAAA@AAAAAAAAA/........./......... 'token_a': { 'name': _('Token A'), 'type': 'string', 'private': True, 'required': True, - 'regex': (r'{}@{}'.format(UUID4_RE, UUID4_RE), 'i'), + 'regex': (r'^{}@{}$'.format(UUID4_RE, UUID4_RE), 'i'), }, + # Token required as part of the API request + # /................../BBBBBBBBB/.......... 'token_b': { 'name': _('Token B'), 'type': 'string', 'private': True, 'required': True, - 'regex': (r'[a-z0-9]{32}', 'i'), + 'regex': (r'^[A-Za-z0-9]{32}$', 'i'), }, + # Token required as part of the API request + # /........./........./CCCCCCCCCCCCCCCCCCCCCCCC 'token_c': { 'name': _('Token C'), 'type': 'string', 'private': True, 'required': True, - 'regex': (UUID4_RE, 'i'), + 'regex': (r'^{}$'.format(UUID4_RE), 'i'), }, }) @@ -164,51 +159,35 @@ class NotifyMSTeams(NotifyBase): """ super(NotifyMSTeams, self).__init__(**kwargs) - if not token_a: - msg = 'The first MSTeams API token is not specified.' - self.logger.warning(msg) - raise TypeError(msg) - - if not token_b: - msg = 'The second MSTeams API token is not specified.' - self.logger.warning(msg) - raise TypeError(msg) - - if not token_c: - msg = 'The third MSTeams API token is not specified.' - self.logger.warning(msg) - raise TypeError(msg) - - if not VALIDATE_TOKEN_A.match(token_a.strip()): - msg = 'The first MSTeams API token specified ({}) is invalid.'\ - .format(token_a) + self.token_a = validate_regex( + token_a, *self.template_tokens['token_a']['regex']) + if not self.token_a: + msg = 'An invalid MSTeams (first) Token ' \ + '({}) was specified.'.format(token_a) self.logger.warning(msg) raise TypeError(msg) - # The token associated with the account - self.token_a = token_a.strip() - - if not VALIDATE_TOKEN_B.match(token_b.strip()): - msg = 'The second MSTeams API token specified ({}) is invalid.'\ - .format(token_b) + self.token_b = validate_regex( + token_b, *self.template_tokens['token_b']['regex']) + if not self.token_b: + msg = 'An invalid MSTeams (second) Token ' \ + '({}) was specified.'.format(token_b) self.logger.warning(msg) raise TypeError(msg) - # The token associated with the account - self.token_b = token_b.strip() - - if not VALIDATE_TOKEN_C.match(token_c.strip()): - msg = 'The third MSTeams API token specified ({}) is invalid.'\ - .format(token_c) + self.token_c = validate_regex( + token_c, *self.template_tokens['token_c']['regex']) + if not self.token_c: + msg = 'An invalid MSTeams (third) Token ' \ + '({}) was specified.'.format(token_c) self.logger.warning(msg) raise TypeError(msg) - # The token associated with the account - self.token_c = token_c.strip() - # Place a thumbnail image inline with the message body self.include_image = include_image + return + def send(self, body, title='', notify_type=NotifyType.INFO, **kwargs): """ Perform Microsoft Teams Notification @@ -293,7 +272,7 @@ class NotifyMSTeams(NotifyBase): return True - def url(self): + def url(self, privacy=False, *args, **kwargs): """ Returns the URL built dynamically based on specified arguments. """ @@ -309,9 +288,9 @@ class NotifyMSTeams(NotifyBase): return '{schema}://{token_a}/{token_b}/{token_c}/'\ '?{args}'.format( schema=self.secure_protocol, - token_a=NotifyMSTeams.quote(self.token_a, safe=''), - token_b=NotifyMSTeams.quote(self.token_b, safe=''), - token_c=NotifyMSTeams.quote(self.token_c, 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=''), args=NotifyMSTeams.urlencode(args), ) @@ -380,7 +359,7 @@ class NotifyMSTeams(NotifyBase): r'IncomingWebhook/' r'(?P<token_b>[A-Z0-9]+)/' r'(?P<token_c>[A-Z0-9-]+)/?' - r'(?P<args>\?[.+])?$', url, re.I) + r'(?P<args>\?.+)?$', url, re.I) if result: return NotifyMSTeams.parse_url( diff --git a/libs/apprise/plugins/NotifyMailgun.py b/libs/apprise/plugins/NotifyMailgun.py index b53982f63..6e2a3b282 100644 --- a/libs/apprise/plugins/NotifyMailgun.py +++ b/libs/apprise/plugins/NotifyMailgun.py @@ -57,6 +57,7 @@ from .NotifyBase import NotifyBase from ..common import NotifyType from ..utils import parse_list from ..utils import is_email +from ..utils import validate_regex from ..AppriseLocale import gettext_lazy as _ # Provide some known codes Mailgun uses and what they translate to: @@ -169,19 +170,17 @@ class NotifyMailgun(NotifyBase): """ super(NotifyMailgun, self).__init__(**kwargs) - try: - # The personal access apikey associated with the account - self.apikey = apikey.strip() - - except AttributeError: - # Token was None - msg = 'No API Key was specified.' + # API Key (associated with project) + self.apikey = validate_regex(apikey) + if not self.apikey: + msg = 'An invalid Mailgun API Key ' \ + '({}) was specified.'.format(apikey) self.logger.warning(msg) raise TypeError(msg) # Validate our username if not self.user: - msg = 'No username was specified.' + msg = 'No Mailgun username was specified.' self.logger.warning(msg) raise TypeError(msg) @@ -198,7 +197,7 @@ class NotifyMailgun(NotifyBase): raise except: # Invalid region specified - msg = 'The region specified ({}) is invalid.' \ + msg = 'The Mailgun region specified ({}) is invalid.' \ .format(region_name) self.logger.warning(msg) raise TypeError(msg) @@ -310,7 +309,7 @@ class NotifyMailgun(NotifyBase): return not has_error - def url(self): + def url(self, privacy=False, *args, **kwargs): """ Returns the URL built dynamically based on specified arguments. """ @@ -331,7 +330,7 @@ class NotifyMailgun(NotifyBase): schema=self.secure_protocol, host=self.host, user=NotifyMailgun.quote(self.user, safe=''), - apikey=NotifyMailgun.quote(self.apikey, safe=''), + apikey=self.pprint(self.apikey, privacy, safe=''), targets='/'.join( [NotifyMailgun.quote(x, safe='') for x in self.targets]), args=NotifyMailgun.urlencode(args)) diff --git a/libs/apprise/plugins/NotifyMatrix.py b/libs/apprise/plugins/NotifyMatrix.py index a5579e538..97ab127cf 100644 --- a/libs/apprise/plugins/NotifyMatrix.py +++ b/libs/apprise/plugins/NotifyMatrix.py @@ -35,6 +35,7 @@ from json import loads from time import time from .NotifyBase import NotifyBase +from ..URLBase import PrivacyMode from ..common import NotifyType from ..common import NotifyImageSize from ..common import NotifyFormat @@ -920,8 +921,11 @@ class NotifyMatrix(NotifyBase): # Return; we're done return (False, response) - except ValueError: + except (AttributeError, TypeError, ValueError): # This gets thrown if we can't parse our JSON Response + # - ValueError = r.content is Unparsable + # - TypeError = r.content is None + # - AttributeError = r is None self.logger.warning('Invalid response from Matrix server.') self.logger.debug( 'Response Details:\r\n{}'.format(r.content)) @@ -946,7 +950,7 @@ class NotifyMatrix(NotifyBase): """ self._logout() - def url(self): + def url(self, privacy=False, *args, **kwargs): """ Returns the URL built dynamically based on specified arguments. """ @@ -965,7 +969,8 @@ class NotifyMatrix(NotifyBase): if self.user and self.password: auth = '{user}:{password}@'.format( user=NotifyMatrix.quote(self.user, safe=''), - password=NotifyMatrix.quote(self.password, safe=''), + password=self.pprint( + self.password, privacy, mode=PrivacyMode.Secret, safe=''), ) elif self.user: diff --git a/libs/apprise/plugins/NotifyMatterMost.py b/libs/apprise/plugins/NotifyMatterMost.py index 57d95e41e..84bb93edd 100644 --- a/libs/apprise/plugins/NotifyMatterMost.py +++ b/libs/apprise/plugins/NotifyMatterMost.py @@ -23,7 +23,6 @@ # 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 @@ -33,15 +32,13 @@ from ..common import NotifyImageSize from ..common import NotifyType from ..utils import parse_bool from ..utils import parse_list +from ..utils import validate_regex from ..AppriseLocale import gettext_lazy as _ # Some Reference Locations: # - https://docs.mattermost.com/developer/webhooks-incoming.html # - https://docs.mattermost.com/administration/config-settings.html -# Used to validate Authorization Token -VALIDATE_AUTHTOKEN = re.compile(r'[a-z0-9]{24,32}', re.I) - class NotifyMatterMost(NotifyBase): """ @@ -97,7 +94,7 @@ class NotifyMatterMost(NotifyBase): 'authtoken': { 'name': _('Access Key'), 'type': 'string', - 'regex': (r'[a-z0-9]{24,32}', 'i'), + 'regex': (r'^[a-z0-9]{24,32}$', 'i'), 'private': True, 'required': True, }, @@ -152,17 +149,12 @@ class NotifyMatterMost(NotifyBase): self.fullpath = '' if not isinstance( fullpath, six.string_types) else fullpath.strip() - # Our Authorization Token - self.authtoken = authtoken - - # Validate authtoken - if not authtoken: - msg = 'Missing MatterMost Authorization Token.' - self.logger.warning(msg) - raise TypeError(msg) - - if not VALIDATE_AUTHTOKEN.match(authtoken): - msg = 'Invalid MatterMost Authorization Token Specified.' + # 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.logger.warning(msg) raise TypeError(msg) @@ -280,7 +272,7 @@ class NotifyMatterMost(NotifyBase): # Return our overall status return not has_error - def url(self): + def url(self, privacy=False, *args, **kwargs): """ Returns the URL built dynamically based on specified arguments. """ @@ -302,15 +294,24 @@ class NotifyMatterMost(NotifyBase): default_port = 443 if self.secure else self.default_port default_schema = self.secure_protocol if self.secure else self.protocol + # Determine if there is a botname present + botname = '' + if self.user: + botname = '{botname}@'.format( + botname=NotifyMatterMost.quote(self.user, safe=''), + ) + return \ - '{schema}://{hostname}{port}{fullpath}{authtoken}/?{args}'.format( + '{schema}://{botname}{hostname}{port}{fullpath}{authtoken}' \ + '/?{args}'.format( schema=default_schema, + botname=botname, hostname=NotifyMatterMost.quote(self.host, safe=''), 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=NotifyMatterMost.quote(self.authtoken, safe=''), + authtoken=self.pprint(self.authtoken, privacy, safe=''), args=NotifyMatterMost.urlencode(args), ) @@ -331,7 +332,6 @@ class NotifyMatterMost(NotifyBase): # all entries before it will be our path tokens = NotifyMatterMost.split_path(results['fullpath']) - # Apply our settings now results['authtoken'] = None if not tokens else tokens.pop() # Store our path diff --git a/libs/apprise/plugins/NotifyNexmo.py b/libs/apprise/plugins/NotifyNexmo.py index 916bdf8ce..db19c759d 100644 --- a/libs/apprise/plugins/NotifyNexmo.py +++ b/libs/apprise/plugins/NotifyNexmo.py @@ -33,14 +33,12 @@ import re import requests 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 _ -# Token required as part of the API request -VALIDATE_APIKEY = re.compile(r'^[a-z0-9]{8}$', re.I) -VALIDATE_SECRET = re.compile(r'^[a-z0-9]{16}$', re.I) - # Some Phone Number Detection IS_PHONE_NO = re.compile(r'^\+?(?P<phone>[0-9\s)(+-]+)\s*$') @@ -93,27 +91,28 @@ class NotifyNexmo(NotifyBase): 'name': _('API Key'), 'type': 'string', 'required': True, - 'regex': (r'AC[a-z0-9]{8}', 'i'), + 'regex': (r'^AC[a-z0-9]{8}$', 'i'), + 'private': True, }, 'secret': { 'name': _('API Secret'), 'type': 'string', 'private': True, 'required': True, - 'regex': (r'[a-z0-9]{16}', 'i'), + 'regex': (r'^[a-z0-9]{16}$', 'i'), }, 'from_phone': { 'name': _('From Phone No'), 'type': 'string', 'required': True, - 'regex': (r'\+?[0-9\s)(+-]+', 'i'), + 'regex': (r'^\+?[0-9\s)(+-]+$', 'i'), 'map_to': 'source', }, 'target_phone': { 'name': _('Target Phone No'), 'type': 'string', 'prefix': '+', - 'regex': (r'[0-9\s)(+-]+', 'i'), + 'regex': (r'^[0-9\s)(+-]+$', 'i'), 'map_to': 'targets', }, 'targets': { @@ -152,35 +151,21 @@ class NotifyNexmo(NotifyBase): """ super(NotifyNexmo, self).__init__(**kwargs) - try: - # The Account SID associated with the account - self.apikey = apikey.strip() - - except AttributeError: - # Token was None - msg = 'No Nexmo APIKey was specified.' + # API Key (associated with project) + self.apikey = validate_regex( + apikey, *self.template_tokens['apikey']['regex']) + if not self.apikey: + msg = 'An invalid Nexmo API Key ' \ + '({}) was specified.'.format(apikey) self.logger.warning(msg) raise TypeError(msg) - if not VALIDATE_APIKEY.match(self.apikey): - msg = 'The Nexmo API Key specified ({}) is invalid.'\ - .format(self.apikey) - self.logger.warning(msg) - raise TypeError(msg) - - try: - # The Account SID associated with the account - self.secret = secret.strip() - - except AttributeError: - # Token was None - msg = 'No Nexmo API Secret was specified.' - self.logger.warning(msg) - raise TypeError(msg) - - if not VALIDATE_SECRET.match(self.secret): - msg = 'The Nexmo API Secret specified ({}) is invalid.'\ - .format(self.secret) + # API Secret (associated with project) + self.secret = validate_regex( + secret, *self.template_tokens['secret']['regex']) + if not self.secret: + msg = 'An invalid Nexmo API Secret ' \ + '({}) was specified.'.format(secret) self.logger.warning(msg) raise TypeError(msg) @@ -241,6 +226,8 @@ class NotifyNexmo(NotifyBase): '({}) specified.'.format(target), ) + return + def send(self, body, title='', notify_type=NotifyType.INFO, **kwargs): """ Perform Nexmo Notification @@ -334,7 +321,7 @@ class NotifyNexmo(NotifyBase): return not has_error - def url(self): + def url(self, privacy=False, *args, **kwargs): """ Returns the URL built dynamically based on specified arguments. """ @@ -349,8 +336,9 @@ class NotifyNexmo(NotifyBase): return '{schema}://{key}:{secret}@{source}/{targets}/?{args}'.format( schema=self.secure_protocol, - key=self.apikey, - secret=self.secret, + key=self.pprint(self.apikey, privacy, safe=''), + secret=self.pprint( + self.secret, privacy, mode=PrivacyMode.Secret, safe=''), source=NotifyNexmo.quote(self.source, safe=''), targets='/'.join( [NotifyNexmo.quote(x, safe='') for x in self.targets]), diff --git a/libs/apprise/plugins/NotifyProwl.py b/libs/apprise/plugins/NotifyProwl.py index 38e6431f1..3f6ca7927 100644 --- a/libs/apprise/plugins/NotifyProwl.py +++ b/libs/apprise/plugins/NotifyProwl.py @@ -23,19 +23,13 @@ # 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 validate_regex from ..AppriseLocale import gettext_lazy as _ -# Used to validate API Key -VALIDATE_APIKEY = re.compile(r'[A-Za-z0-9]{40}') - -# Used to validate Provider Key -VALIDATE_PROVIDERKEY = re.compile(r'[A-Za-z0-9]{40}') - # Priorities class ProwlPriority(object): @@ -104,11 +98,13 @@ class NotifyProwl(NotifyBase): 'type': 'string', 'private': True, 'required': True, + 'regex': (r'^[A-Za-z0-9]{40}$', 'i'), }, 'providerkey': { 'name': _('Provider Key'), 'type': 'string', 'private': True, + 'regex': (r'^[A-Za-z0-9]{40}$', 'i'), }, }) @@ -129,31 +125,35 @@ class NotifyProwl(NotifyBase): super(NotifyProwl, self).__init__(**kwargs) if priority not in PROWL_PRIORITIES: - self.priority = ProwlPriority.NORMAL + self.priority = self.template_args['priority']['default'] else: self.priority = priority - if not VALIDATE_APIKEY.match(apikey): - msg = 'The API key specified ({}) is invalid.'.format(apikey) + # API Key (associated with project) + self.apikey = validate_regex( + apikey, *self.template_tokens['apikey']['regex']) + if not self.apikey: + msg = 'An invalid Prowl API Key ' \ + '({}) was specified.'.format(apikey) self.logger.warning(msg) raise TypeError(msg) - # Store the API key - self.apikey = apikey - # Store the provider key (if specified) if providerkey: - if not VALIDATE_PROVIDERKEY.match(providerkey): - msg = \ - 'The Provider key specified ({}) is invalid.' \ - .format(providerkey) - + self.providerkey = validate_regex( + providerkey, *self.template_tokens['providerkey']['regex']) + if not self.providerkey: + msg = 'An invalid Prowl Provider Key ' \ + '({}) was specified.'.format(providerkey) self.logger.warning(msg) raise TypeError(msg) - # Store the Provider Key - self.providerkey = providerkey + else: + # No provider key was set + self.providerkey = None + + return def send(self, body, title='', notify_type=NotifyType.INFO, **kwargs): """ @@ -223,7 +223,7 @@ class NotifyProwl(NotifyBase): return True - def url(self): + def url(self, privacy=False, *args, **kwargs): """ Returns the URL built dynamically based on specified arguments. """ @@ -247,9 +247,8 @@ class NotifyProwl(NotifyBase): return '{schema}://{apikey}/{providerkey}/?{args}'.format( schema=self.secure_protocol, - apikey=NotifyProwl.quote(self.apikey, safe=''), - providerkey='' if not self.providerkey - else NotifyProwl.quote(self.providerkey, safe=''), + apikey=self.pprint(self.apikey, privacy, safe=''), + providerkey=self.pprint(self.providerkey, privacy, safe=''), args=NotifyProwl.urlencode(args), ) diff --git a/libs/apprise/plugins/NotifyPushBullet.py b/libs/apprise/plugins/NotifyPushBullet.py index 50af8be62..af239c40c 100644 --- a/libs/apprise/plugins/NotifyPushBullet.py +++ b/libs/apprise/plugins/NotifyPushBullet.py @@ -25,12 +25,15 @@ import requests from json import dumps +from json import loads from .NotifyBase import NotifyBase from ..utils import GET_EMAIL_RE from ..common import NotifyType from ..utils import parse_list +from ..utils import validate_regex from ..AppriseLocale import gettext_lazy as _ +from ..attachment.AttachBase import AttachBase # Flag used as a placeholder to sending to all devices PUSHBULLET_SEND_TO_ALL = 'ALL_DEVICES' @@ -55,11 +58,15 @@ class NotifyPushBullet(NotifyBase): # The default secure protocol secure_protocol = 'pbul' + # Allow 50 requests per minute (Tier 2). + # 60/50 = 0.2 + request_rate_per_sec = 1.2 + # A URL that takes you to the setup/help of the specific protocol setup_url = 'https://github.com/caronc/apprise/wiki/Notify_pushbullet' # PushBullet uses the http protocol with JSON requests - notify_url = 'https://api.pushbullet.com/v2/pushes' + notify_url = 'https://api.pushbullet.com/v2/{}' # Define object templates templates = ( @@ -110,32 +117,100 @@ class NotifyPushBullet(NotifyBase): """ super(NotifyPushBullet, self).__init__(**kwargs) - self.accesstoken = accesstoken + # Access Token (associated with project) + self.accesstoken = validate_regex(accesstoken) + if not self.accesstoken: + msg = 'An invalid PushBullet Access Token ' \ + '({}) was specified.'.format(accesstoken) + self.logger.warning(msg) + raise TypeError(msg) self.targets = parse_list(targets) if len(self.targets) == 0: self.targets = (PUSHBULLET_SEND_TO_ALL, ) - def send(self, body, title='', notify_type=NotifyType.INFO, **kwargs): + return + + def send(self, body, title='', notify_type=NotifyType.INFO, attach=None, + **kwargs): """ Perform PushBullet Notification """ - headers = { - 'User-Agent': self.app_id, - 'Content-Type': 'application/json' - } - auth = (self.accesstoken, '') - # error tracking (used for function return) has_error = False + # Build a list of our attachments + attachments = [] + + if attach: + # We need to upload our payload first so that we can source it + # in remaining messages + for attachment in attach: + # prepare payload + payload = { + 'file_name': attachment.name, + 'file_type': attachment.mimetype, + } + # First thing we need to do is make a request so that we can + # get a URL to post our request to. + # see: https://docs.pushbullet.com/#upload-request + okay, response = self._send( + self.notify_url.format('upload-request'), payload) + if not okay: + # We can't post our attachment + return False + + # If we get here, our output will look something like this: + # { + # "file_name": "cat.jpg", + # "file_type": "image/jpeg", + # "file_url": "https://dl.pushb.com/abc/cat.jpg", + # "upload_url": "https://upload.pushbullet.com/abcd123" + # } + + # - The file_url is where the file will be available after it + # is uploaded. + # - The upload_url is where to POST the file to. The file must + # be posted using multipart/form-data encoding. + + # Prepare our attachment payload; we'll use this if we + # successfully upload the content below for later on. + try: + # By placing this in a try/except block we can validate + # our response at the same time as preparing our payload + payload = { + # PushBullet v2/pushes file type: + 'type': 'file', + 'file_name': response['file_name'], + 'file_type': response['file_type'], + 'file_url': response['file_url'], + } + + if response['file_type'].startswith('image/'): + # Allow image to be displayed inline (if image type) + payload['image_url'] = response['file_url'] + + upload_url = response['upload_url'] + + except (KeyError, TypeError): + # A method of verifying our content exists + return False + + okay, response = self._send(upload_url, attachment) + if not okay: + # We can't post our attachment + return False + + # Save our pre-prepared payload for attachment posting + attachments.append(payload) + # Create a copy of the targets list targets = list(self.targets) while len(targets): recipient = targets.pop(0) - # prepare JSON Object + # prepare payload payload = { 'type': 'note', 'title': title, @@ -157,65 +232,132 @@ class NotifyPushBullet(NotifyBase): else: payload['device_iden'] = recipient - self.logger.debug( - "Recipient '%s' is a device" % recipient) + self.logger.debug("Recipient '%s' is a device" % recipient) - self.logger.debug('PushBullet POST URL: %s (cert_verify=%r)' % ( - self.notify_url, self.verify_certificate, - )) - self.logger.debug('PushBullet Payload: %s' % str(payload)) + okay, response = self._send( + self.notify_url.format('pushes'), payload) + if not okay: + has_error = True + continue - # Always call throttle before any remote server i/o is made - self.throttle() + self.logger.info( + 'Sent PushBullet notification to "%s".' % (recipient)) - try: - r = requests.post( - self.notify_url, - data=dumps(payload), - headers=headers, - auth=auth, - verify=self.verify_certificate, - ) - - if r.status_code != requests.codes.ok: - # We had a problem - status_str = \ - NotifyPushBullet.http_response_code_lookup( - r.status_code, PUSHBULLET_HTTP_ERROR_MAP) - - self.logger.warning( - 'Failed to send PushBullet notification to {}:' - '{}{}error={}.'.format( - recipient, - status_str, - ', ' if status_str else '', - r.status_code)) - - self.logger.debug( - 'Response Details:\r\n{}'.format(r.content)) - - # Mark our failure + for attach_payload in attachments: + # Send our attachments to our same user (already prepared as + # our payload object) + okay, response = self._send( + self.notify_url.format('pushes'), attach_payload) + if not okay: has_error = True continue - else: - self.logger.info( - 'Sent PushBullet notification to "%s".' % (recipient)) + self.logger.info( + 'Sent PushBullet attachment (%s) to "%s".' % ( + attach_payload['file_name'], recipient)) + + return not has_error + + def _send(self, url, payload, **kwargs): + """ + Wrapper to the requests (post) object + """ + + headers = { + 'User-Agent': self.app_id, + } + + # Some default values for our request object to which we'll update + # depending on what our payload is + files = None + data = None + + if not isinstance(payload, AttachBase): + # Send our payload as a JSON object + headers['Content-Type'] = 'application/json' + data = dumps(payload) if payload else None + + auth = (self.accesstoken, '') + + self.logger.debug('PushBullet POST URL: %s (cert_verify=%r)' % ( + url, self.verify_certificate, + )) + self.logger.debug('PushBullet Payload: %s' % str(payload)) + + # Always call throttle before any remote server i/o is made + self.throttle() + + # Default response type + response = None + + try: + # Open our attachment path if required: + if isinstance(payload, AttachBase): + files = {'file': (payload.name, open(payload.path, 'rb'))} + + r = requests.post( + url, + data=data, + headers=headers, + files=files, + auth=auth, + verify=self.verify_certificate, + ) + + try: + response = loads(r.content) + + except (AttributeError, TypeError, ValueError): + # ValueError = r.content is Unparsable + # TypeError = r.content is None + # AttributeError = r is None + + # Fall back to the existing unparsed value + response = r.content + + if r.status_code not in ( + requests.codes.ok, requests.codes.no_content): + # We had a problem + status_str = \ + NotifyPushBullet.http_response_code_lookup( + r.status_code, PUSHBULLET_HTTP_ERROR_MAP) - except requests.RequestException as e: self.logger.warning( - 'A Connection error occured sending PushBullet ' - 'notification to "%s".' % (recipient), - ) - self.logger.debug('Socket Exception: %s' % str(e)) + 'Failed to deliver payload to PushBullet:' + '{}{}error={}.'.format( + status_str, + ', ' if status_str else '', + r.status_code)) - # Mark our failure - has_error = True - continue + self.logger.debug( + 'Response Details:\r\n{}'.format(r.content)) - return not has_error + return False, response + + # otherwise we were successful + return True, response + + except requests.RequestException as e: + self.logger.warning( + 'A Connection error occured communicating with PushBullet.') + self.logger.debug('Socket Exception: %s' % str(e)) + + return False, response + + except (OSError, IOError) as e: + self.logger.warning( + 'An I/O error occured while reading {}.'.format( + payload.name if payload else 'attachment')) + self.logger.debug('I/O Exception: %s' % str(e)) + return False, response + + finally: + # Close our file (if it's open) stored in the second element + # of our files tuple (index 1) + if files: + files['file'][1].close() - def url(self): + def url(self, privacy=False, *args, **kwargs): """ Returns the URL built dynamically based on specified arguments. """ @@ -235,7 +377,7 @@ class NotifyPushBullet(NotifyBase): return '{schema}://{accesstoken}/{targets}/?{args}'.format( schema=self.secure_protocol, - accesstoken=NotifyPushBullet.quote(self.accesstoken, safe=''), + accesstoken=self.pprint(self.accesstoken, privacy, safe=''), targets=targets, args=NotifyPushBullet.urlencode(args)) diff --git a/libs/apprise/plugins/NotifyPushed.py b/libs/apprise/plugins/NotifyPushed.py index fa2261380..35e390d70 100644 --- a/libs/apprise/plugins/NotifyPushed.py +++ b/libs/apprise/plugins/NotifyPushed.py @@ -29,12 +29,14 @@ from json import dumps from itertools import chain 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 _ # Used to detect and parse channels -IS_CHANNEL = re.compile(r'^#(?P<name>[A-Za-z0-9]+)$') +IS_CHANNEL = re.compile(r'^#?(?P<name>[A-Za-z0-9]+)$') # Used to detect and parse a users push id IS_USER_PUSHED_ID = re.compile(r'^@(?P<name>[A-Za-z0-9]+)$') @@ -120,13 +122,19 @@ class NotifyPushed(NotifyBase): """ super(NotifyPushed, self).__init__(**kwargs) - if not app_key: - msg = 'An invalid Application Key was specified.' + # Application Key (associated with project) + self.app_key = validate_regex(app_key) + if not self.app_key: + msg = 'An invalid Pushed Application Key ' \ + '({}) was specified.'.format(app_key) self.logger.warning(msg) raise TypeError(msg) - if not app_secret: - msg = 'An invalid Application Secret was specified.' + # Access Secret (associated with project) + self.app_secret = validate_regex(app_secret) + if not self.app_secret: + msg = 'An invalid Pushed Application Secret ' \ + '({}) was specified.'.format(app_secret) self.logger.warning(msg) raise TypeError(msg) @@ -136,28 +144,34 @@ class NotifyPushed(NotifyBase): # Initialize user list self.users = list() - # Validate recipients and drop bad ones: - for target in parse_list(targets): - result = IS_CHANNEL.match(target) - if result: - # store valid device - self.channels.append(result.group('name')) - continue + # Get our targets + targets = parse_list(targets) + if targets: + # Validate recipients and drop bad ones: + for target in targets: + result = IS_CHANNEL.match(target) + if result: + # store valid device + self.channels.append(result.group('name')) + continue + + result = IS_USER_PUSHED_ID.match(target) + if result: + # store valid room + self.users.append(result.group('name')) + continue - result = IS_USER_PUSHED_ID.match(target) - if result: - # store valid room - self.users.append(result.group('name')) - continue - - self.logger.warning( - 'Dropped invalid channel/userid ' - '(%s) specified.' % target, - ) + self.logger.warning( + 'Dropped invalid channel/userid ' + '(%s) specified.' % target, + ) - # Store our data - self.app_key = app_key - self.app_secret = app_secret + if len(self.channels) + len(self.users) == 0: + # We have no valid channels or users to notify after + # explicitly identifying at least one. + msg = 'No Pushed targets to notify.' + self.logger.warning(msg) + raise TypeError(msg) return @@ -285,7 +299,7 @@ class NotifyPushed(NotifyBase): return True - def url(self): + def url(self, privacy=False, *args, **kwargs): """ Returns the URL built dynamically based on specified arguments. """ @@ -299,8 +313,9 @@ class NotifyPushed(NotifyBase): return '{schema}://{app_key}/{app_secret}/{targets}/?{args}'.format( schema=self.secure_protocol, - app_key=NotifyPushed.quote(self.app_key, safe=''), - app_secret=NotifyPushed.quote(self.app_secret, safe=''), + app_key=self.pprint(self.app_key, privacy, safe=''), + app_secret=self.pprint( + self.app_secret, privacy, mode=PrivacyMode.Secret, safe=''), targets='/'.join( [NotifyPushed.quote(x) for x in chain( # Channels are prefixed with a pound/hashtag symbol @@ -323,8 +338,6 @@ class NotifyPushed(NotifyBase): # We're done early as we couldn't load the results return results - # Apply our settings now - # The first token is stored in the hostname app_key = NotifyPushed.unquote(results['host']) diff --git a/libs/apprise/plugins/NotifyPushjet/__init__.py b/libs/apprise/plugins/NotifyPushjet/__init__.py deleted file mode 100644 index a71fe7e92..000000000 --- a/libs/apprise/plugins/NotifyPushjet/__init__.py +++ /dev/null @@ -1,175 +0,0 @@ -# -*- coding: utf-8 -*- -# -# Copyright (C) 2019 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 -from . import pushjet - -from ..NotifyBase import NotifyBase -from ...common import NotifyType -from ...AppriseLocale import gettext_lazy as _ - -PUBLIC_KEY_RE = re.compile( - r'^[a-z0-9]{4}-[a-z0-9]{6}-[a-z0-9]{12}-[a-z0-9]{5}-[a-z0-9]{9}$', re.I) - -SECRET_KEY_RE = re.compile(r'^[a-z0-9]{32}$', re.I) - - -class NotifyPushjet(NotifyBase): - """ - A wrapper for Pushjet Notifications - """ - - # The default descriptive name associated with the Notification - service_name = 'Pushjet' - - # The default protocol - protocol = 'pjet' - - # The default secure protocol - secure_protocol = 'pjets' - - # A URL that takes you to the setup/help of the specific protocol - setup_url = 'https://github.com/caronc/apprise/wiki/Notify_pushjet' - - # Disable throttle rate for Pushjet requests since they are normally - # local anyway (the remote/online service is no more) - request_rate_per_sec = 0 - - # Define object templates - templates = ( - '{schema}://{secret_key}@{host}', - '{schema}://{secret_key}@{host}:{port}', - ) - - # Define our tokens - template_tokens = dict(NotifyBase.template_tokens, **{ - 'host': { - 'name': _('Hostname'), - 'type': 'string', - 'required': True, - }, - 'port': { - 'name': _('Port'), - 'type': 'int', - 'min': 1, - 'max': 65535, - }, - 'secret_key': { - 'name': _('Secret Key'), - 'type': 'string', - 'required': True, - 'private': True, - }, - }) - - def __init__(self, secret_key, **kwargs): - """ - Initialize Pushjet Object - """ - super(NotifyPushjet, self).__init__(**kwargs) - - if not secret_key: - # You must provide a Pushjet key to work with - msg = 'You must specify a Pushjet Secret Key.' - self.logger.warning(msg) - raise TypeError(msg) - - # store our key - self.secret_key = secret_key - - def send(self, body, title='', notify_type=NotifyType.INFO, **kwargs): - """ - Perform Pushjet Notification - """ - # Always call throttle before any remote server i/o is made - self.throttle() - - server = "https://" if self.secure else "http://" - - server += self.host - if self.port: - server += ":" + str(self.port) - - try: - api = pushjet.pushjet.Api(server) - service = api.Service(secret_key=self.secret_key) - - service.send(body, title) - self.logger.info('Sent Pushjet notification.') - - except (pushjet.errors.PushjetError, ValueError) as e: - self.logger.warning('Failed to send Pushjet notification.') - self.logger.debug('Pushjet Exception: %s' % str(e)) - return False - - return True - - def url(self): - """ - 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', - } - - default_port = 443 if self.secure else 80 - - return '{schema}://{secret_key}@{hostname}{port}/?{args}'.format( - schema=self.secure_protocol if self.secure else self.protocol, - secret_key=NotifyPushjet.quote(self.secret_key, safe=''), - hostname=NotifyPushjet.quote(self.host, safe=''), - port='' if self.port is None or self.port == default_port - else ':{}'.format(self.port), - args=NotifyPushjet.urlencode(args), - ) - - @staticmethod - def parse_url(url): - """ - Parses the URL and returns enough arguments that can allow - us to substantiate this object. - - Syntax: - pjet://secret_key@hostname - pjet://secret_key@hostname:port - pjets://secret_key@hostname - pjets://secret_key@hostname:port - - """ - results = NotifyBase.parse_url(url) - - if not results: - # We're done early as we couldn't load the results - return results - - # Store it as it's value - results['secret_key'] = \ - NotifyPushjet.unquote(results.get('user')) - - return results diff --git a/libs/apprise/plugins/NotifyPushjet/pushjet/__init__.py b/libs/apprise/plugins/NotifyPushjet/pushjet/__init__.py deleted file mode 100644 index 929160dab..000000000 --- a/libs/apprise/plugins/NotifyPushjet/pushjet/__init__.py +++ /dev/null @@ -1,6 +0,0 @@ -# -*- coding: utf-8 -*- - -"""A Python API for Pushjet. Send notifications to your phone from Python scripts!""" - -from .pushjet import Service, Device, Subscription, Message, Api -from .errors import PushjetError, AccessError, NonexistentError, SubscriptionError, RequestError, ServerError diff --git a/libs/apprise/plugins/NotifyPushjet/pushjet/errors.py b/libs/apprise/plugins/NotifyPushjet/pushjet/errors.py deleted file mode 100644 index bfa16b2a5..000000000 --- a/libs/apprise/plugins/NotifyPushjet/pushjet/errors.py +++ /dev/null @@ -1,48 +0,0 @@ -# -*- coding: utf-8 -*- - - -from requests import RequestException - -import sys -if sys.version_info[0] < 3: - # This is built into Python 3. - class ConnectionError(Exception): - pass - -class PushjetError(Exception): - """All the errors inherit from this. Therefore, ``except PushjetError`` catches all errors.""" - -class AccessError(PushjetError): - """Raised when a secret key is missing for a service method that needs one.""" - -class NonexistentError(PushjetError): - """Raised when an attempt to access a nonexistent service is made.""" - -class SubscriptionError(PushjetError): - """Raised when an attempt to subscribe to a service that's already subscribed to, - or to unsubscribe from a service that isn't subscribed to, is made.""" - -class RequestError(PushjetError, ConnectionError): - """Raised if something goes wrong in the connection to the API server. - Inherits from ``ConnectionError`` on Python 3, and can therefore be caught - with ``except ConnectionError`` there. - - :ivar requests_exception: The underlying `requests <http://docs.python-requests.org>`__ - exception. Access this if you want to handle different HTTP request errors in different ways. - """ - - def __str__(self): - return "requests.{error}: {description}".format( - error=self.requests_exception.__class__.__name__, - description=str(self.requests_exception) - ) - - def __init__(self, requests_exception): - self.requests_exception = requests_exception - -class ServerError(PushjetError): - """Raised if the API server has an error while processing your request. - This getting raised means there's a bug in the server! If you manage to - track down what caused it, you can `open an issue on Pushjet's GitHub page - <https://github.com/Pushjet/Pushjet-Server-Api/issues>`__. - """ diff --git a/libs/apprise/plugins/NotifyPushjet/pushjet/pushjet.py b/libs/apprise/plugins/NotifyPushjet/pushjet/pushjet.py deleted file mode 100644 index 1289f3f08..000000000 --- a/libs/apprise/plugins/NotifyPushjet/pushjet/pushjet.py +++ /dev/null @@ -1,313 +0,0 @@ -# -*- coding: utf-8 -*- - -import sys -import requests -from functools import partial - -from six import text_type -from six.moves.urllib.parse import urljoin - -from .utilities import ( - NoNoneDict, - requires_secret_key, with_api_bound, - is_valid_uuid, is_valid_public_key, is_valid_secret_key, repr_format -) -from .errors import NonexistentError, SubscriptionError, RequestError, ServerError - -DEFAULT_API_URL = 'https://api.pushjet.io/' - -class PushjetModel(object): - _api = None # This is filled in later. - -class Service(PushjetModel): - """A Pushjet service to send messages through. To receive messages, devices - subscribe to these. - - :param secret_key: The service's API key for write access. If provided, - :func:`~pushjet.Service.send`, :func:`~pushjet.Service.edit`, and - :func:`~pushjet.Service.delete` become available. - Either this or the public key parameter must be present. - :param public_key: The service's public API key for read access only. - Either this or the secret key parameter must be present. - - :ivar name: The name of the service. - :ivar icon_url: The URL to the service's icon. May be ``None``. - :ivar created: When the service was created, as seconds from epoch. - :ivar secret_key: The service's secret API key, or ``None`` if the service is read-only. - :ivar public_key: The service's public API key, to be used when subscribing to the service. - """ - - def __repr__(self): - return "<Pushjet Service: \"{}\">".format(repr_format(self.name)) - - def __init__(self, secret_key=None, public_key=None): - if secret_key is None and public_key is None: - raise ValueError("Either a secret key or public key " - "must be provided.") - elif secret_key and not is_valid_secret_key(secret_key): - raise ValueError("Invalid secret key provided.") - elif public_key and not is_valid_public_key(public_key): - raise ValueError("Invalid public key provided.") - self.secret_key = text_type(secret_key) if secret_key else None - self.public_key = text_type(public_key) if public_key else None - self.refresh() - - def _request(self, endpoint, method, is_secret, params=None, data=None): - params = params or {} - if is_secret: - params['secret'] = self.secret_key - else: - params['service'] = self.public_key - return self._api._request(endpoint, method, params, data) - - @requires_secret_key - def send(self, message, title=None, link=None, importance=None): - """Send a message to the service's subscribers. - - :param message: The message body to be sent. - :param title: (optional) The message's title. Messages can be without title. - :param link: (optional) An URL to be sent with the message. - :param importance: (optional) The priority level of the message. May be - a number between 1 and 5, where 1 is least important and 5 is most. - """ - data = NoNoneDict({ - 'message': message, - 'title': title, - 'link': link, - 'level': importance - }) - self._request('message', 'POST', is_secret=True, data=data) - - @requires_secret_key - def edit(self, name=None, icon_url=None): - """Edit the service's attributes. - - :param name: (optional) A new name to give the service. - :param icon_url: (optional) A new URL to use as the service's icon URL. - Set to an empty string to remove the service's icon entirely. - """ - data = NoNoneDict({ - 'name': name, - 'icon': icon_url - }) - if not data: - return - self._request('service', 'PATCH', is_secret=True, data=data) - self.name = text_type(name) - self.icon_url = text_type(icon_url) - - @requires_secret_key - def delete(self): - """Delete the service. Irreversible.""" - self._request('service', 'DELETE', is_secret=True) - - def _update_from_data(self, data): - self.name = data['name'] - self.icon_url = data['icon'] or None - self.created = data['created'] - self.public_key = data['public'] - self.secret_key = data.get('secret', getattr(self, 'secret_key', None)) - - def refresh(self): - """Refresh the server's information, in case it could be edited from elsewhere. - - :raises: :exc:`~pushjet.NonexistentError` if the service was deleted before refreshing. - """ - key_name = 'public' - secret = False - if self.secret_key is not None: - key_name = 'secret' - secret = True - - status, response = self._request('service', 'GET', is_secret=secret) - if status == requests.codes.NOT_FOUND: - raise NonexistentError("A service with the provided {} key " - "does not exist (anymore, at least).".format(key_name)) - self._update_from_data(response['service']) - - @classmethod - def _from_data(cls, data): - # This might be a no-no, but I see little alternative if - # different constructors with different parameters are needed, - # *and* a default __init__ constructor should be present. - # This, along with the subclassing for custom API URLs, may - # very well be one of those pieces of code you look back at - # years down the line - or maybe just a couple of weeks - and say - # "what the heck was I thinking"? I assure you, though, future me. - # This was the most reasonable thing to get the API + argspecs I wanted. - obj = cls.__new__(cls) - obj._update_from_data(data) - return obj - - @classmethod - def create(cls, name, icon_url=None): - """Create a new service. - - :param name: The name of the new service. - :param icon_url: (optional) An URL to an image to be used as the service's icon. - :return: The newly-created :class:`~pushjet.Service`. - """ - data = NoNoneDict({ - 'name': name, - 'icon': icon_url - }) - _, response = cls._api._request('service', 'POST', data=data) - return cls._from_data(response['service']) - -class Device(PushjetModel): - """The "receiver" for messages. Subscribes to services and receives any - messages they send. - - :param uuid: The device's unique ID as a UUID. Does not need to be registered - before using it. A UUID can be generated with ``uuid.uuid4()``, for example. - :ivar uuid: The UUID the device was initialized with. - """ - - def __repr__(self): - return "<Pushjet Device: {}>".format(self.uuid) - - def __init__(self, uuid): - uuid = text_type(uuid) - if not is_valid_uuid(uuid): - raise ValueError("Invalid UUID provided. Try uuid.uuid4().") - self.uuid = text_type(uuid) - - def _request(self, endpoint, method, params=None, data=None): - params = (params or {}) - params['uuid'] = self.uuid - return self._api._request(endpoint, method, params, data) - - def subscribe(self, service): - """Subscribe the device to a service. - - :param service: The service to subscribe to. May be a public key or a :class:`~pushjet.Service`. - :return: The :class:`~pushjet.Service` subscribed to. - - :raises: :exc:`~pushjet.NonexistentError` if the provided service does not exist. - :raises: :exc:`~pushjet.SubscriptionError` if the provided service is already subscribed to. - """ - data = {} - data['service'] = service.public_key if isinstance(service, Service) else service - status, response = self._request('subscription', 'POST', data=data) - if status == requests.codes.CONFLICT: - raise SubscriptionError("The device is already subscribed to that service.") - elif status == requests.codes.NOT_FOUND: - raise NonexistentError("A service with the provided public key " - "does not exist (anymore, at least).") - return self._api.Service._from_data(response['service']) - - def unsubscribe(self, service): - """Unsubscribe the device from a service. - - :param service: The service to unsubscribe from. May be a public key or a :class:`~pushjet.Service`. - :raises: :exc:`~pushjet.NonexistentError` if the provided service does not exist. - :raises: :exc:`~pushjet.SubscriptionError` if the provided service isn't subscribed to. - """ - data = {} - data['service'] = service.public_key if isinstance(service, Service) else service - status, _ = self._request('subscription', 'DELETE', data=data) - if status == requests.codes.CONFLICT: - raise SubscriptionError("The device is not subscribed to that service.") - elif status == requests.codes.NOT_FOUND: - raise NonexistentError("A service with the provided public key " - "does not exist (anymore, at least).") - - def get_subscriptions(self): - """Get all the subscriptions the device has. - - :return: A list of :class:`~pushjet.Subscription`. - """ - _, response = self._request('subscription', 'GET') - subscriptions = [] - for subscription_dict in response['subscriptions']: - subscriptions.append(Subscription(subscription_dict)) - return subscriptions - - def get_messages(self): - """Get all new (that is, as of yet unretrieved) messages. - - :return: A list of :class:`~pushjet.Message`. - """ - _, response = self._request('message', 'GET') - messages = [] - for message_dict in response['messages']: - messages.append(Message(message_dict)) - return messages - -class Subscription(object): - """A subscription to a service, with the metadata that entails. - - :ivar service: The service the subscription is to, as a :class:`~pushjet.Service`. - :ivar time_subscribed: When the subscription was made, as seconds from epoch. - :ivar last_checked: When the device last retrieved messages from the subscription, - as seconds from epoch. - :ivar device_uuid: The UUID of the device that owns the subscription. - """ - - def __repr__(self): - return "<Pushjet Subscription to service \"{}\">".format(repr_format(self.service.name)) - - def __init__(self, subscription_dict): - self.service = Service._from_data(subscription_dict['service']) - self.time_subscribed = subscription_dict['timestamp'] - self.last_checked = subscription_dict['timestamp_checked'] - self.device_uuid = subscription_dict['uuid'] # Not sure this is needed, but... - -class Message(object): - """A message received from a service. - - :ivar message: The message body. - :ivar title: The message title. May be ``None``. - :ivar link: The URL the message links to. May be ``None``. - :ivar time_sent: When the message was sent, as seconds from epoch. - :ivar importance: The message's priority level between 1 and 5, where 1 is - least important and 5 is most. - :ivar service: The :class:`~pushjet.Service` that sent the message. - """ - - def __repr__(self): - return "<Pushjet Message: \"{}\">".format(repr_format(self.title or self.message)) - - def __init__(self, message_dict): - self.message = message_dict['message'] - self.title = message_dict['title'] or None - self.link = message_dict['link'] or None - self.time_sent = message_dict['timestamp'] - self.importance = message_dict['level'] - self.service = Service._from_data(message_dict['service']) - -class Api(object): - """An API with a custom URL. Use this if you're connecting to a self-hosted - Pushjet API instance, or a non-standard one in general. - - :param url: The URL to the API instance. - :ivar url: The URL to the API instance, as supplied. - """ - - def __repr__(self): - return "<Pushjet Api: {}>".format(self.url).encode(sys.stdout.encoding, errors='replace') - - def __init__(self, url): - self.url = text_type(url) - self.Service = with_api_bound(Service, self) - self.Device = with_api_bound(Device, self) - - def _request(self, endpoint, method, params=None, data=None): - url = urljoin(self.url, endpoint) - try: - r = requests.request(method, url, params=params, data=data) - except requests.RequestException as e: - raise RequestError(e) - status = r.status_code - if status == requests.codes.INTERNAL_SERVER_ERROR: - raise ServerError( - "An error occurred in the server while processing your request. " - "This should probably be reported to: " - "https://github.com/Pushjet/Pushjet-Server-Api/issues" - ) - try: - response = r.json() - except ValueError: - response = {} - return status, response - diff --git a/libs/apprise/plugins/NotifyPushjet/pushjet/utilities.py b/libs/apprise/plugins/NotifyPushjet/pushjet/utilities.py deleted file mode 100644 index 3a2af502b..000000000 --- a/libs/apprise/plugins/NotifyPushjet/pushjet/utilities.py +++ /dev/null @@ -1,64 +0,0 @@ -# -*- coding: utf-8 -*- - -import re -import sys -from decorator import decorator -from .errors import AccessError - -# Help class(...es? Nah. Just singular for now.) - -class NoNoneDict(dict): - """A dict that ignores values that are None. Not completely API-compatible - with dict, but contains all that's needed. - """ - def __repr__(self): - return "NoNoneDict({dict})".format(dict=dict.__repr__(self)) - - def __init__(self, initial={}): - self.update(initial) - - def __setitem__(self, key, value): - if value is not None: - dict.__setitem__(self, key, value) - - def update(self, data): - for key, value in data.items(): - self[key] = value - -# Decorators / factories - -@decorator -def requires_secret_key(func, self, *args, **kwargs): - """Raise an error if the method is called without a secret key.""" - if self.secret_key is None: - raise AccessError("The Service doesn't have a secret " - "key provided, and therefore lacks write permission.") - return func(self, *args, **kwargs) - -def with_api_bound(cls, api): - new_cls = type(cls.__name__, (cls,), { - '_api': api, - '__doc__': ( - "Create a :class:`~pushjet.{name}` bound to the API. " - "See :class:`pushjet.{name}` for documentation." - ).format(name=cls.__name__) - }) - return new_cls - -# Helper functions - -UUID_RE = re.compile(r'^[A-Fa-f0-9]{8}-[A-Fa-f0-9]{4}-[A-Fa-f0-9]{4}-[A-Fa-f0-9]{4}-[A-Fa-f0-9]{12}$') -PUBLIC_KEY_RE = re.compile(r'^[A-Za-z0-9]{4}-[A-Za-z0-9]{6}-[A-Za-z0-9]{12}-[A-Za-z0-9]{5}-[A-Za-z0-9]{9}$') -SECRET_KEY_RE = re.compile(r'^[A-Za-z0-9]{32}$') - -is_valid_uuid = lambda s: UUID_RE.match(s) is not None -is_valid_public_key = lambda s: PUBLIC_KEY_RE.match(s) is not None -is_valid_secret_key = lambda s: SECRET_KEY_RE.match(s) is not None - -def repr_format(s): - s = s.replace('\n', ' ').replace('\r', '') - original_length = len(s) - s = s[:30] - s += '...' if len(s) != original_length else '' - s = s.encode(sys.stdout.encoding, errors='replace') - return s diff --git a/libs/apprise/plugins/NotifyPushover.py b/libs/apprise/plugins/NotifyPushover.py index ebc77ae04..58fb63cb6 100644 --- a/libs/apprise/plugins/NotifyPushover.py +++ b/libs/apprise/plugins/NotifyPushover.py @@ -30,18 +30,13 @@ import requests from .NotifyBase import NotifyBase from ..common import NotifyType from ..utils import parse_list +from ..utils import validate_regex from ..AppriseLocale import gettext_lazy as _ # Flag used as a placeholder to sending to all devices PUSHOVER_SEND_TO_ALL = 'ALL_DEVICES' -# Used to validate API Key -VALIDATE_TOKEN = re.compile(r'^[a-z0-9]{30}$', re.I) - -# Used to detect a User and/or Group -VALIDATE_USER_KEY = re.compile(r'^[a-z0-9]{30}$', re.I) - -# Used to detect a User and/or Group +# Used to detect a Device VALIDATE_DEVICE = re.compile(r'^[a-z0-9_]{1,25}$', re.I) @@ -158,20 +153,19 @@ class NotifyPushover(NotifyBase): 'type': 'string', 'private': True, 'required': True, - 'regex': (r'[a-z0-9]{30}', 'i'), - 'map_to': 'user', + 'regex': (r'^[a-z0-9]{30}$', 'i'), }, 'token': { 'name': _('Access Token'), 'type': 'string', 'private': True, 'required': True, - 'regex': (r'[a-z0-9]{30}', 'i'), + 'regex': (r'^[a-z0-9]{30}$', 'i'), }, 'target_device': { 'name': _('Target Device'), 'type': 'string', - 'regex': (r'[a-z0-9_]{1,25}', 'i'), + 'regex': (r'^[a-z0-9_]{1,25}$', 'i'), 'map_to': 'targets', }, 'targets': { @@ -191,7 +185,7 @@ class NotifyPushover(NotifyBase): 'sound': { 'name': _('Sound'), 'type': 'string', - 'regex': (r'[a-z]{1,12}', 'i'), + 'regex': (r'^[a-z]{1,12}$', 'i'), 'default': PushoverSound.PUSHOVER, }, 'retry': { @@ -212,26 +206,28 @@ class NotifyPushover(NotifyBase): }, }) - def __init__(self, token, targets=None, priority=None, sound=None, - retry=None, expire=None, - **kwargs): + def __init__(self, user_key, token, targets=None, priority=None, + sound=None, retry=None, expire=None, **kwargs): """ Initialize Pushover Object """ super(NotifyPushover, self).__init__(**kwargs) - try: - # The token associated with the account - self.token = token.strip() - - except AttributeError: - # Token was None - msg = 'No API Token was specified.' + # Access Token (associated with project) + self.token = validate_regex( + token, *self.template_tokens['token']['regex']) + if not self.token: + msg = 'An invalid Pushover Access Token ' \ + '({}) was specified.'.format(token) self.logger.warning(msg) raise TypeError(msg) - if not VALIDATE_TOKEN.match(self.token): - msg = 'The API Token specified (%s) is invalid.'.format(token) + # User Key (associated with project) + self.user_key = validate_regex( + user_key, *self.template_tokens['user_key']['regex']) + if not self.user_key: + msg = 'An invalid Pushover User Key ' \ + '({}) was specified.'.format(user_key) self.logger.warning(msg) raise TypeError(msg) @@ -249,7 +245,7 @@ class NotifyPushover(NotifyBase): # The Priority of the message if priority not in PUSHOVER_PRIORITIES: - self.priority = PushoverPriority.NORMAL + self.priority = self.template_args['priority']['default'] else: self.priority = priority @@ -258,7 +254,7 @@ class NotifyPushover(NotifyBase): if self.priority == PushoverPriority.EMERGENCY: # How often to resend notification, in seconds - self.retry = NotifyPushover.template_args['retry']['default'] + self.retry = self.template_args['retry']['default'] try: self.retry = int(retry) except (ValueError, TypeError): @@ -266,7 +262,7 @@ class NotifyPushover(NotifyBase): pass # How often to resend notification, in seconds - self.expire = NotifyPushover.template_args['expire']['default'] + self.expire = self.template_args['expire']['default'] try: self.expire = int(expire) except (ValueError, TypeError): @@ -274,23 +270,16 @@ class NotifyPushover(NotifyBase): pass if self.retry < 30: - msg = 'Retry must be at least 30.' + msg = 'Pushover retry must be at least 30 seconds.' self.logger.warning(msg) raise TypeError(msg) + if self.expire < 0 or self.expire > 10800: - msg = 'Expire has a max value of at most 10800 seconds.' + msg = 'Pushover expire must reside in the range of ' \ + '0 to 10800 seconds.' self.logger.warning(msg) raise TypeError(msg) - - if not self.user: - msg = 'No user key was specified.' - self.logger.warning(msg) - raise TypeError(msg) - - if not VALIDATE_USER_KEY.match(self.user): - msg = 'The user key specified (%s) is invalid.' % self.user - self.logger.warning(msg) - raise TypeError(msg) + return def send(self, body, title='', notify_type=NotifyType.INFO, **kwargs): """ @@ -323,7 +312,7 @@ class NotifyPushover(NotifyBase): # prepare JSON Object payload = { 'token': self.token, - 'user': self.user, + 'user': self.user_key, 'priority': str(self.priority), 'title': title, 'message': body, @@ -388,7 +377,7 @@ class NotifyPushover(NotifyBase): return not has_error - def url(self): + def url(self, privacy=False, *args, **kwargs): """ Returns the URL built dynamically based on specified arguments. """ @@ -406,8 +395,8 @@ class NotifyPushover(NotifyBase): 'format': self.notify_format, 'overflow': self.overflow_mode, 'priority': - _map[PushoverPriority.NORMAL] if self.priority not in _map - else _map[self.priority], + _map[self.template_args['priority']['default']] + if self.priority not in _map else _map[self.priority], 'verify': 'yes' if self.verify_certificate else 'no', } # Only add expire and retry for emergency messages, @@ -424,12 +413,10 @@ class NotifyPushover(NotifyBase): # it from the devices list devices = '' - return '{schema}://{auth}{token}/{devices}/?{args}'.format( + return '{schema}://{user_key}@{token}/{devices}/?{args}'.format( schema=self.secure_protocol, - auth='' if not self.user - else '{user}@'.format( - user=NotifyPushover.quote(self.user, safe='')), - token=NotifyPushover.quote(self.token, safe=''), + user_key=self.pprint(self.user_key, privacy, safe=''), + token=self.pprint(self.token, privacy, safe=''), devices=devices, args=NotifyPushover.urlencode(args)) @@ -466,6 +453,9 @@ class NotifyPushover(NotifyBase): # Retrieve all of our targets results['targets'] = NotifyPushover.split_path(results['fullpath']) + # User Key is retrieved from the user + results['user_key'] = NotifyPushover.unquote(results['user']) + # Get the sound if 'sound' in results['qsd'] and len(results['qsd']['sound']): results['sound'] = \ diff --git a/libs/apprise/plugins/NotifyRocketChat.py b/libs/apprise/plugins/NotifyRocketChat.py index b0860e34b..cca947edc 100644 --- a/libs/apprise/plugins/NotifyRocketChat.py +++ b/libs/apprise/plugins/NotifyRocketChat.py @@ -31,6 +31,7 @@ from json import dumps from itertools import chain from .NotifyBase import NotifyBase +from ..URLBase import PrivacyMode from ..common import NotifyImageSize from ..common import NotifyFormat from ..common import NotifyType @@ -279,7 +280,7 @@ class NotifyRocketChat(NotifyBase): return - def url(self): + def url(self, privacy=False, *args, **kwargs): """ Returns the URL built dynamically based on specified arguments. """ @@ -297,13 +298,14 @@ class NotifyRocketChat(NotifyBase): if self.mode == RocketChatAuthMode.BASIC: auth = '{user}:{password}@'.format( user=NotifyRocketChat.quote(self.user, safe=''), - password=NotifyRocketChat.quote(self.password, safe=''), + password=self.pprint( + self.password, privacy, mode=PrivacyMode.Secret, safe=''), ) else: auth = '{user}{webhook}@'.format( user='{}:'.format(NotifyRocketChat.quote(self.user, safe='')) if self.user else '', - webhook=NotifyRocketChat.quote(self.webhook, safe=''), + webhook=self.pprint(self.webhook, privacy, safe=''), ) default_port = 443 if self.secure else 80 @@ -562,9 +564,19 @@ class NotifyRocketChat(NotifyBase): self.headers['X-User-Id'] = response.get( 'data', {'userId': None}).get('userId') + except (AttributeError, TypeError, ValueError): + # Our response was not the JSON type we had expected it to be + # - ValueError = r.content is Unparsable + # - TypeError = r.content is None + # - AttributeError = r is None + self.logger.warning( + 'A commuication error occured authenticating {} on ' + 'Rocket.Chat.'.format(self.user)) + return False + except requests.RequestException as e: self.logger.warning( - 'A Connection error occured authenticating {} on ' + 'A connection error occured authenticating {} on ' 'Rocket.Chat.'.format(self.user)) self.logger.debug('Socket Exception: %s' % str(e)) return False diff --git a/libs/apprise/plugins/NotifyRyver.py b/libs/apprise/plugins/NotifyRyver.py index ebc67de1c..b34b56686 100644 --- a/libs/apprise/plugins/NotifyRyver.py +++ b/libs/apprise/plugins/NotifyRyver.py @@ -40,14 +40,9 @@ from .NotifyBase import NotifyBase from ..common import NotifyImageSize from ..common import NotifyType from ..utils import parse_bool +from ..utils import validate_regex from ..AppriseLocale import gettext_lazy as _ -# Token required as part of the API request -VALIDATE_TOKEN = re.compile(r'[A-Z0-9]{15}', re.I) - -# Organization required as part of the API request -VALIDATE_ORG = re.compile(r'[A-Z0-9_-]{3,32}', re.I) - class RyverWebhookMode(object): """ @@ -99,12 +94,14 @@ class NotifyRyver(NotifyBase): 'name': _('Organization'), 'type': 'string', 'required': True, + 'regex': (r'^[A-Z0-9_-]{3,32}$', 'i'), }, 'token': { 'name': _('Token'), 'type': 'string', 'required': True, 'private': True, + 'regex': (r'^[A-Z0-9]{15}$', 'i'), }, 'user': { 'name': _('Bot Name'), @@ -135,25 +132,21 @@ class NotifyRyver(NotifyBase): """ super(NotifyRyver, self).__init__(**kwargs) - if not token: - msg = 'No Ryver token was specified.' - self.logger.warning(msg) - raise TypeError(msg) - - if not organization: - msg = 'No Ryver organization was specified.' + # API Token (associated with project) + self.token = validate_regex( + token, *self.template_tokens['token']['regex']) + if not self.token: + msg = 'An invalid Ryver API Token ' \ + '({}) was specified.'.format(token) self.logger.warning(msg) raise TypeError(msg) - if not VALIDATE_TOKEN.match(token.strip()): - msg = 'The Ryver token specified ({}) is invalid.'\ - .format(token) - self.logger.warning(msg) - raise TypeError(msg) - - if not VALIDATE_ORG.match(organization.strip()): - msg = 'The Ryver organization specified ({}) is invalid.'\ - .format(organization) + # Organization (associated with project) + self.organization = validate_regex( + organization, *self.template_tokens['organization']['regex']) + if not self.organization: + msg = 'An invalid Ryver Organization ' \ + '({}) was specified.'.format(organization) self.logger.warning(msg) raise TypeError(msg) @@ -167,12 +160,6 @@ class NotifyRyver(NotifyBase): self.logger.warning(msg) raise TypeError(msg) - # The organization associated with the account - self.organization = organization.strip() - - # The token associated with the account - self.token = token.strip() - # Place an image inline with the message body self.include_image = include_image @@ -193,6 +180,8 @@ class NotifyRyver(NotifyBase): re.IGNORECASE, ) + return + def send(self, body, title='', notify_type=NotifyType.INFO, **kwargs): """ Perform Ryver Notification @@ -279,7 +268,7 @@ class NotifyRyver(NotifyBase): return True - def url(self): + def url(self, privacy=False, *args, **kwargs): """ Returns the URL built dynamically based on specified arguments. """ @@ -304,7 +293,7 @@ class NotifyRyver(NotifyBase): schema=self.secure_protocol, botname=botname, organization=NotifyRyver.quote(self.organization, safe=''), - token=NotifyRyver.quote(self.token, safe=''), + token=self.pprint(self.token, privacy, safe=''), args=NotifyRyver.urlencode(args), ) @@ -363,7 +352,7 @@ class NotifyRyver(NotifyBase): result = re.match( r'^https?://(?P<org>[A-Z0-9_-]+)\.ryver\.com/application/webhook/' r'(?P<webhook_token>[A-Z0-9]+)/?' - r'(?P<args>\?[.+])?$', url, re.I) + r'(?P<args>\?.+)?$', url, re.I) if result: return NotifyRyver.parse_url( diff --git a/libs/apprise/plugins/NotifySNS.py b/libs/apprise/plugins/NotifySNS.py index c7509eb00..a547558c5 100644 --- a/libs/apprise/plugins/NotifySNS.py +++ b/libs/apprise/plugins/NotifySNS.py @@ -33,8 +33,10 @@ from xml.etree import ElementTree from itertools import chain 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 _ # Some Phone Number Detection @@ -116,21 +118,21 @@ class NotifySNS(NotifyBase): 'name': _('Region'), 'type': 'string', 'required': True, - 'regex': (r'[a-z]{2}-[a-z]+-[0-9]+', 'i'), + 'regex': (r'^[a-z]{2}-[a-z]+-[0-9]+$', 'i'), 'map_to': 'region_name', }, 'target_phone_no': { 'name': _('Target Phone No'), 'type': 'string', 'map_to': 'targets', - 'regex': (r'[0-9\s)(+-]+', 'i') + 'regex': (r'^[0-9\s)(+-]+$', 'i') }, 'target_topic': { 'name': _('Target Topic'), 'type': 'string', 'map_to': 'targets', 'prefix': '#', - 'regex': (r'[A-Za-z0-9_-]+', 'i'), + 'regex': (r'^[A-Za-z0-9_-]+$', 'i'), }, 'targets': { 'name': _('Targets'), @@ -152,18 +154,28 @@ class NotifySNS(NotifyBase): """ super(NotifySNS, self).__init__(**kwargs) - if not access_key_id: + # Store our AWS API Access Key + self.aws_access_key_id = validate_regex(access_key_id) + if not self.aws_access_key_id: msg = 'An invalid AWS Access Key ID was specified.' self.logger.warning(msg) raise TypeError(msg) - if not secret_access_key: - msg = 'An invalid AWS Secret Access Key was specified.' + # Store our AWS API Secret Access key + self.aws_secret_access_key = validate_regex(secret_access_key) + if not self.aws_secret_access_key: + msg = 'An invalid AWS Secret Access Key ' \ + '({}) was specified.'.format(secret_access_key) self.logger.warning(msg) raise TypeError(msg) - if not (region_name and IS_REGION.match(region_name)): - msg = 'An invalid AWS Region was specified.' + # Acquire our AWS Region Name: + # eg. us-east-1, cn-north-1, us-west-2, ... + self.aws_region_name = validate_regex( + region_name, *self.template_tokens['region']['regex']) + if not self.aws_region_name: + msg = 'An invalid AWS Region ({}) was specified.'.format( + region_name) self.logger.warning(msg) raise TypeError(msg) @@ -173,16 +185,6 @@ class NotifySNS(NotifyBase): # Initialize numbers list self.phone = list() - # Store our AWS API Key - self.aws_access_key_id = access_key_id - - # Store our AWS API Secret Access key - self.aws_secret_access_key = secret_access_key - - # Acquire our AWS Region Name: - # eg. us-east-1, cn-north-1, us-west-2, ... - self.aws_region_name = region_name - # Set our notify_url based on our region self.notify_url = 'https://sns.{}.amazonaws.com/'\ .format(self.aws_region_name) @@ -230,8 +232,12 @@ class NotifySNS(NotifyBase): ) if len(self.phone) == 0 and len(self.topics) == 0: - self.logger.warning( - 'There are no valid target(s) identified to notify.') + # 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): """ @@ -568,7 +574,7 @@ class NotifySNS(NotifyBase): return response - def url(self): + def url(self, privacy=False, *args, **kwargs): """ Returns the URL built dynamically based on specified arguments. """ @@ -583,9 +589,10 @@ class NotifySNS(NotifyBase): return '{schema}://{key_id}/{key_secret}/{region}/{targets}/'\ '?{args}'.format( schema=self.secure_protocol, - key_id=NotifySNS.quote(self.aws_access_key_id, safe=''), - key_secret=NotifySNS.quote( - self.aws_secret_access_key, safe=''), + key_id=self.pprint(self.aws_access_key_id, privacy, safe=''), + key_secret=self.pprint( + self.aws_secret_access_key, privacy, + mode=PrivacyMode.Secret, safe=''), region=NotifySNS.quote(self.aws_region_name, safe=''), targets='/'.join( [NotifySNS.quote(x) for x in chain( diff --git a/libs/apprise/plugins/NotifySlack.py b/libs/apprise/plugins/NotifySlack.py index 17a7ebbc6..e16885e60 100644 --- a/libs/apprise/plugins/NotifySlack.py +++ b/libs/apprise/plugins/NotifySlack.py @@ -23,21 +23,41 @@ # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN # THE SOFTWARE. -# To use this plugin, you need to first access https://api.slack.com -# Specifically https://my.slack.com/services/new/incoming-webhook/ -# to create a new incoming webhook for your account. You'll need to -# follow the wizard to pre-determine the channel(s) you want your -# message to broadcast to, and when you're complete, you will -# recieve a URL that looks something like this: -# https://hooks.slack.com/services/T1JJ3T3L2/A1BRTD4JD/TIiajkdnlazkcOXrIdevi7FQ -# ^ ^ ^ -# | | | -# These are important <--------------^---------^---------------^ +# There are 2 ways to use this plugin... +# Method 1: Via Webhook: +# Visit https://my.slack.com/services/new/incoming-webhook/ +# to create a new incoming webhook for your account. You'll need to +# follow the wizard to pre-determine the channel(s) you want your +# message to broadcast to, and when you're complete, you will +# recieve a URL that looks something like this: +# https://hooks.slack.com/services/T1JJ3T3L2/A1BRTD4JD/TIiajkdnlazkcOXrIdevi7 +# ^ ^ ^ +# | | | +# These are important <--------------^---------^---------------^ # +# Method 2: Via a Bot: +# 1. visit: https://api.slack.com/apps?new_app=1 +# 2. Pick an App Name (such as Apprise) and select your workspace. Then +# press 'Create App' +# 3. You'll be able to click on 'Bots' from here where you can then choose +# 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. +# 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} # +# ... which might look something like: +# slack://xoxp-1234-1234-1234-4ddbc191d40ee098cbaae6f3523ada2d +# ... or: +# slack://xoxb-1234-1234-4ddbc191d40ee098cbaae6f3523ada2d +# + import re import requests from json import dumps +from json import loads from time import time from .NotifyBase import NotifyBase @@ -46,20 +66,9 @@ from ..common import NotifyType from ..common import NotifyFormat from ..utils import parse_bool from ..utils import parse_list +from ..utils import validate_regex from ..AppriseLocale import gettext_lazy as _ -# Token required as part of the API request -# /AAAAAAAAA/........./........................ -VALIDATE_TOKEN_A = re.compile(r'[A-Z0-9]{9}') - -# Token required as part of the API request -# /........./BBBBBBBBB/........................ -VALIDATE_TOKEN_B = re.compile(r'[A-Z0-9]{9}') - -# Token required as part of the API request -# /........./........./CCCCCCCCCCCCCCCCCCCCCCCC -VALIDATE_TOKEN_C = re.compile(r'[A-Za-z0-9]{24}') - # Extend HTTP Error Messages SLACK_HTTP_ERROR_MAP = { 401: 'Unauthorized - Invalid Token.', @@ -68,8 +77,26 @@ SLACK_HTTP_ERROR_MAP = { # Used to break path apart into list of channels CHANNEL_LIST_DELIM = re.compile(r'[ \t\r\n,#\\/]+') -# Used to detect a channel -IS_VALID_TARGET_RE = re.compile(r'[+#@]?([A-Z0-9_]{1,32})', re.I) + +class SlackMode(object): + """ + Tracks the mode of which we're using Slack + """ + # We're dealing with a webhook + # Our token looks like: T1JJ3T3L2/A1BRTD4JD/TIiajkdnlazkcOXrIdevi7 + WEBHOOK = 'webhook' + + # We're dealing with a bot (using the OAuth Access Token) + # Our token looks like: xoxp-1234-1234-1234-abc124 or + # Our token looks like: xoxb-1234-1234-abc124 or + BOT = 'bot' + + +# Define our Slack Modes +SLACK_MODES = ( + SlackMode.WEBHOOK, + SlackMode.BOT, +) class NotifySlack(NotifyBase): @@ -86,27 +113,43 @@ class NotifySlack(NotifyBase): # The default secure protocol secure_protocol = 'slack' + # Allow 50 requests per minute (Tier 2). + # 60/50 = 0.2 + request_rate_per_sec = 1.2 + # A URL that takes you to the setup/help of the specific protocol setup_url = 'https://github.com/caronc/apprise/wiki/Notify_slack' - # Slack uses the http protocol with JSON requests - notify_url = 'https://hooks.slack.com/services' + # Slack Webhook URL + webhook_url = 'https://hooks.slack.com/services' + + # Slack API URL (used with Bots) + api_url = 'https://slack.com/api/{}' # Allows the user to specify the NotifyImageSize object image_size = NotifyImageSize.XY_72 # The maximum allowable characters allowed in the body per message - body_maxlen = 1000 + body_maxlen = 35000 # Default Notification Format notify_format = NotifyFormat.MARKDOWN + # Bot's do not have default channels to notify; so #general + # becomes the default channel in BOT mode + default_notification_channel = '#general' + # Define object templates templates = ( + # Webhook '{schema}://{token_a}/{token_b}{token_c}', '{schema}://{botname}@{token_a}/{token_b}{token_c}', '{schema}://{token_a}/{token_b}{token_c}/{targets}', '{schema}://{botname}@{token_a}/{token_b}{token_c}/{targets}', + + # Bot + '{schema}://{access_token}/', + '{schema}://{access_token}/{targets}', ) # Define our template tokens @@ -116,26 +159,42 @@ class NotifySlack(NotifyBase): 'type': 'string', 'map_to': 'user', }, + # Bot User OAuth Access Token + # which always starts with xoxp- e.g.: + # xoxb-1234-1234-4ddbc191d40ee098cbaae6f3523ada2d + 'access_token': { + 'name': _('OAuth Access Token'), + 'type': 'string', + 'private': True, + 'required': True, + 'regex': (r'^xox[abp]-[A-Z0-9-]+$', 'i'), + }, + # Token required as part of the Webhook request + # /AAAAAAAAA/........./........................ 'token_a': { 'name': _('Token A'), 'type': 'string', 'private': True, 'required': True, - 'regex': (r'[A-Z0-9]{9}', 'i'), + 'regex': (r'^[A-Z0-9]{9}$', 'i'), }, + # Token required as part of the Webhook request + # /........./BBBBBBBBB/........................ 'token_b': { 'name': _('Token B'), 'type': 'string', 'private': True, 'required': True, - 'regex': (r'[A-Z0-9]{9}', 'i'), + 'regex': (r'^[A-Z0-9]{9}$', 'i'), }, + # Token required as part of the Webhook request + # /........./........./CCCCCCCCCCCCCCCCCCCCCCCC 'token_c': { 'name': _('Token C'), 'type': 'string', 'private': True, 'required': True, - 'regex': (r'[A-Za-z0-9]{24}', 'i'), + 'regex': (r'^[A-Za-z0-9]{24}$', 'i'), }, 'target_encoded_id': { 'name': _('Target Encoded ID'), @@ -169,59 +228,60 @@ class NotifySlack(NotifyBase): 'default': True, 'map_to': 'include_image', }, + 'footer': { + 'name': _('Include Footer'), + 'type': 'bool', + 'default': True, + 'map_to': 'include_footer', + }, 'to': { 'alias_of': 'targets', }, }) - def __init__(self, token_a, token_b, token_c, targets, - include_image=True, **kwargs): + def __init__(self, access_token=None, token_a=None, token_b=None, + token_c=None, targets=None, include_image=True, + include_footer=True, **kwargs): """ Initialize Slack Object """ super(NotifySlack, self).__init__(**kwargs) - if not token_a: - msg = 'The first API token is not specified.' - self.logger.warning(msg) - raise TypeError(msg) - - if not token_b: - msg = 'The second API token is not specified.' - self.logger.warning(msg) - raise TypeError(msg) - - if not token_c: - msg = 'The third API token is not specified.' - self.logger.warning(msg) - raise TypeError(msg) - - if not VALIDATE_TOKEN_A.match(token_a.strip()): - msg = 'The first API token specified ({}) is invalid.'\ - .format(token_a) - self.logger.warning(msg) - raise TypeError(msg) - - # The token associated with the account - self.token_a = token_a.strip() - - if not VALIDATE_TOKEN_B.match(token_b.strip()): - msg = 'The second API token specified ({}) is invalid.'\ - .format(token_b) - self.logger.warning(msg) - raise TypeError(msg) - - # The token associated with the account - self.token_b = token_b.strip() - - if not VALIDATE_TOKEN_C.match(token_c.strip()): - msg = 'The third API token specified ({}) is invalid.'\ - .format(token_c) - self.logger.warning(msg) - raise TypeError(msg) - - # The token associated with the account - self.token_c = token_c.strip() + # Setup our mode + self.mode = SlackMode.BOT if access_token else SlackMode.WEBHOOK + + if self.mode is SlackMode.WEBHOOK: + self.token_a = validate_regex( + token_a, *self.template_tokens['token_a']['regex']) + if not self.token_a: + msg = 'An invalid Slack (first) Token ' \ + '({}) was specified.'.format(token_a) + self.logger.warning(msg) + raise TypeError(msg) + + self.token_b = validate_regex( + token_b, *self.template_tokens['token_b']['regex']) + if not self.token_b: + msg = 'An invalid Slack (second) Token ' \ + '({}) was specified.'.format(token_b) + self.logger.warning(msg) + raise TypeError(msg) + + self.token_c = validate_regex( + token_c, *self.template_tokens['token_c']['regex']) + if not self.token_c: + msg = 'An invalid Slack (third) Token ' \ + '({}) was specified.'.format(token_c) + self.logger.warning(msg) + raise TypeError(msg) + else: + self.access_token = validate_regex( + access_token, *self.template_tokens['access_token']['regex']) + if not self.access_token: + msg = 'An invalid Slack OAuth Access Token ' \ + '({}) was specified.'.format(access_token) + self.logger.warning(msg) + raise TypeError(msg) if not self.user: self.logger.warning( @@ -233,7 +293,9 @@ class NotifySlack(NotifyBase): # No problem; the webhook is smart enough to just notify the # channel it was created for; adding 'None' is just used as # a flag lower to not set the channels - self.channels.append(None) + self.channels.append( + None if self.mode is SlackMode.WEBHOOK + else self.default_notification_channel) # Formatting requirements are defined here: # https://api.slack.com/docs/message-formatting @@ -255,16 +317,16 @@ class NotifySlack(NotifyBase): # Place a thumbnail image inline with the message body self.include_image = include_image - def send(self, body, title='', notify_type=NotifyType.INFO, **kwargs): + # Place a footer with each post + self.include_footer = include_footer + return + + def send(self, body, title='', notify_type=NotifyType.INFO, attach=None, + **kwargs): """ Perform Slack Notification """ - headers = { - 'User-Agent': self.app_id, - 'Content-Type': 'application/json', - } - # error tracking (used for function return) has_error = False @@ -275,14 +337,8 @@ class NotifySlack(NotifyBase): body = self._re_formatting_rules.sub( # pragma: no branch lambda x: self._re_formatting_map[x.group()], body, ) - url = '%s/%s/%s/%s' % ( - self.notify_url, - self.token_a, - self.token_b, - self.token_c, - ) - # prepare JSON Object + # Prepare JSON Object (applicable to both WEBHOOK and BOT mode) payload = { 'username': self.user if self.user else self.app_id, # Use Markdown language @@ -293,102 +349,287 @@ class NotifySlack(NotifyBase): 'color': self.color(notify_type), # Time 'ts': time(), - 'footer': self.app_id, }], } + # 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, + ) + + else: # SlackMode.BOT + url = self.api_url.format('chat.postMessage') + + if self.include_footer: + # 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 + # attachments if using the Webhook mode + self.logger.warning( + 'Slack Webhooks do not support attachments.') + # Create a copy of the channel list channels = list(self.channels) + + attach_channel_list = [] while len(channels): channel = channels.pop(0) if channel is not None: - # Channel over-ride was specified - if not IS_VALID_TARGET_RE.match(channel): + _channel = validate_regex( + channel, r'[+#@]?(?P<value>[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 len(_channel) > 1 and _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 len(_channel) > 1 and _channel[0] == '@': # Treat @ value 'as is' - payload['channel'] = channel + payload['channel'] = _channel else: # Prefix with channel hash tag - payload['channel'] = '#%s' % channel + payload['channel'] = '#{}'.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['attachments'][0]['footer_icon'] = image_url + payload['icon_url'] = image_url - self.logger.debug('Slack POST URL: %s (cert_verify=%r)' % ( - url, self.verify_certificate, - )) - self.logger.debug('Slack Payload: %s' % str(payload)) + if self.include_footer: + payload['attachments'][0]['footer_icon'] = image_url - # 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 = \ - NotifySlack.http_response_code_lookup( - r.status_code, SLACK_HTTP_ERROR_MAP) + response = self._send(url, payload) + if not response: + # Handle any error + has_error = True + continue - self.logger.warning( - 'Failed to send Slack notification{}: ' - '{}{}error={}.'.format( - ' to {}'.format(channel) - if channel is not None else '', - status_str, - ', ' if status_str else '', - r.status_code)) + self.logger.info( + 'Sent Slack notification{}.'.format( + ' to {}'.format(channel) + if channel is not None else '')) - self.logger.debug( - 'Response Details:\r\n{}'.format(r.content)) + if attach and self.mode is SlackMode.BOT and attach_channel_list: + # Send our attachments (can only be done in bot mode) + for attachment in attach: + self.logger.info( + 'Posting Slack Attachment {}'.format(attachment.name)) - # Mark our failure - has_error = True - continue + # Prepare API Upload Payload + _payload = { + 'filename': attachment.name, + 'channels': ','.join(attach_channel_list) + } - else: - self.logger.info( - 'Sent Slack notification{}.'.format( - ' to {}'.format(channel) - if channel is not None else '')) + # Our URL + _url = self.api_url.format('files.upload') + + response = self._send(_url, _payload, attach=attachment) + if not (response and response.get('file') and + response['file'].get('url_private')): + # We failed to post our attachments, take an early exit + return False + + return not has_error + + def _send(self, url, payload, attach=None, **kwargs): + """ + Wrapper to the requests (post) object + """ + + self.logger.debug('Slack POST URL: %s (cert_verify=%r)' % ( + url, self.verify_certificate, + )) + self.logger.debug('Slack Payload: %s' % str(payload)) + + headers = { + 'User-Agent': self.app_id, + } + + if not attach: + headers['Content-Type'] = 'application/json; charset=utf-8' + + if self.mode is SlackMode.BOT: + headers['Authorization'] = 'Bearer {}'.format(self.access_token) + + # Our response object + response = None + + # 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'))} + + r = requests.post( + url, + data=payload if attach else dumps(payload), + headers=headers, + files=files, + verify=self.verify_certificate, + ) + + if r.status_code != requests.codes.ok: + # We had a problem + status_str = \ + NotifySlack.http_response_code_lookup( + r.status_code, SLACK_HTTP_ERROR_MAP) - except requests.RequestException as e: self.logger.warning( - 'A Connection error occured sending Slack ' - 'notification{}.'.format( - ' to {}'.format(channel) - if channel is not None else '')) - self.logger.debug('Socket Exception: %s' % str(e)) + 'Failed to send {}to Slack: ' + '{}{}error={}.'.format( + attach.name if attach else '', + status_str, + ', ' if status_str else '', + r.status_code)) - # Mark our failure - has_error = True - continue + self.logger.debug( + 'Response Details:\r\n{}'.format(r.content)) + return False - return not has_error + 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 + + # Message Post Response looks like this: + # { + # "attachments": [ + # { + # "color": "3AA3E3", + # "fallback": "test", + # "id": 1, + # "text": "my body", + # "title": "my title", + # "ts": 1573694687 + # } + # ], + # "bot_id": "BAK4K23G5", + # "icons": { + # "image_48": "https://s3-us-west-2.amazonaws.com/... + # }, + # "subtype": "bot_message", + # "text": "", + # "ts": "1573694689.003700", + # "type": "message", + # "username": "Apprise" + # } + + # File Attachment Responses look like this + # { + # "file": { + # "channels": [], + # "comments_count": 0, + # "created": 1573617523, + # "display_as_bot": false, + # "editable": false, + # "external_type": "", + # "filetype": "png", + # "groups": [], + # "has_rich_preview": false, + # "id": "FQJJLDAHM", + # "image_exif_rotation": 1, + # "ims": [], + # "is_external": false, + # "is_public": false, + # "is_starred": false, + # "mimetype": "image/png", + # "mode": "hosted", + # "name": "apprise-test.png", + # "original_h": 640, + # "original_w": 640, + # "permalink": "https://{name}.slack.com/files/... + # "permalink_public": "https://slack-files.com/... + # "pretty_type": "PNG", + # "public_url_shared": false, + # "shares": {}, + # "size": 238810, + # "thumb_160": "https://files.slack.com/files-tmb/... + # "thumb_360": "https://files.slack.com/files-tmb/... + # "thumb_360_h": 360, + # "thumb_360_w": 360, + # "thumb_480": "https://files.slack.com/files-tmb/... + # "thumb_480_h": 480, + # "thumb_480_w": 480, + # "thumb_64": "https://files.slack.com/files-tmb/... + # "thumb_80": "https://files.slack.com/files-tmb/... + # "thumb_tiny": abcd... + # "timestamp": 1573617523, + # "title": "apprise-test", + # "url_private": "https://files.slack.com/files-pri/... + # "url_private_download": "https://files.slack.com/files-... + # "user": "UADKLLMJT", + # "username": "" + # }, + # "ok": true + # } + except requests.RequestException as e: + self.logger.warning( + 'A Connection error occured posting {}to Slack.'.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 - def url(self): + 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 the response for processing + return response + + def url(self, privacy=False, *args, **kwargs): """ Returns the URL built dynamically based on specified arguments. """ @@ -398,23 +639,35 @@ class NotifySlack(NotifyBase): 'format': self.notify_format, 'overflow': self.overflow_mode, 'image': 'yes' if self.include_image else 'no', + 'footer': 'yes' if self.include_footer else 'no', 'verify': 'yes' if self.verify_certificate else 'no', } - # 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: + # Determine if there is a botname present + botname = '' + if self.user: + botname = '{botname}@'.format( + botname=NotifySlack.quote(self.user, safe=''), + ) - return '{schema}://{botname}{token_a}/{token_b}/{token_c}/{targets}/'\ + return '{schema}://{botname}{token_a}/{token_b}/{token_c}/'\ + '{targets}/?{args}'.format( + schema=self.secure_protocol, + botname=botname, + 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=''), + targets='/'.join( + [NotifySlack.quote(x, safe='') + for x in self.channels]), + args=NotifySlack.urlencode(args), + ) + # else -> self.mode == SlackMode.BOT: + return '{schema}://{access_token}/{targets}/'\ '?{args}'.format( schema=self.secure_protocol, - botname=botname, - token_a=NotifySlack.quote(self.token_a, safe=''), - token_b=NotifySlack.quote(self.token_b, safe=''), - token_c=NotifySlack.quote(self.token_c, safe=''), + access_token=self.pprint(self.access_token, privacy, safe=''), targets='/'.join( [NotifySlack.quote(x, safe='') for x in self.channels]), args=NotifySlack.urlencode(args), @@ -427,32 +680,40 @@ class NotifySlack(NotifyBase): us to substantiate this object. """ - results = NotifyBase.parse_url(url) - + 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 first token is stored in the hostname + token = NotifySlack.unquote(results['host']) + # Get unquoted entries entries = NotifySlack.split_path(results['fullpath']) - # The first token is stored in the hostname - results['token_a'] = NotifySlack.unquote(results['host']) + # Verify if our token_a us a bot token or part of a webhook: + if token.startswith('xo'): + # We're dealing with a bot + results['access_token'] = token - # Now fetch the remaining tokens - try: - results['token_b'] = entries.pop(0) + else: + # We're dealing with a webhook + results['token_a'] = token - except IndexError: - # We're done - results['token_b'] = None + # Now fetch the remaining tokens + try: + results['token_b'] = entries.pop(0) - try: - results['token_c'] = entries.pop(0) + except IndexError: + # We're done + results['token_b'] = None - except IndexError: - # We're done - results['token_c'] = None + try: + results['token_c'] = entries.pop(0) + + except IndexError: + # We're done + results['token_c'] = None # assign remaining entries to the channels we wish to notify results['targets'] = entries @@ -464,10 +725,14 @@ class NotifySlack(NotifyBase): bool, CHANNEL_LIST_DELIM.split( NotifySlack.unquote(results['qsd']['to'])))] - # Get Image + # Get Image Flag results['include_image'] = \ parse_bool(results['qsd'].get('image', True)) + # Get Footer Flag + results['include_footer'] = \ + parse_bool(results['qsd'].get('footer', True)) + return results @staticmethod @@ -478,10 +743,10 @@ class NotifySlack(NotifyBase): result = re.match( r'^https?://hooks\.slack\.com/services/' - r'(?P<token_a>[A-Z0-9]{9})/' - r'(?P<token_b>[A-Z0-9]{9})/' - r'(?P<token_c>[A-Z0-9]{24})/?' - r'(?P<args>\?[.+])?$', url, re.I) + r'(?P<token_a>[A-Z0-9]+)/' + r'(?P<token_b>[A-Z0-9]+)/' + r'(?P<token_c>[A-Z0-9]+)/?' + r'(?P<args>\?.+)?$', url, re.I) if result: return NotifySlack.parse_url( diff --git a/libs/apprise/plugins/NotifyTechulusPush.py b/libs/apprise/plugins/NotifyTechulusPush.py index 53f7b461a..6614decdc 100644 --- a/libs/apprise/plugins/NotifyTechulusPush.py +++ b/libs/apprise/plugins/NotifyTechulusPush.py @@ -47,12 +47,12 @@ # - https://push.techulus.com/ - Main Website # - https://pushtechulus.docs.apiary.io - API Documentation -import re 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 _ # Token required as part of the API request @@ -60,9 +60,6 @@ from ..AppriseLocale import gettext_lazy as _ 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}' -# API Key -VALIDATE_APIKEY = re.compile(UUID4_RE, re.I) - class NotifyTechulusPush(NotifyBase): """ @@ -99,7 +96,7 @@ class NotifyTechulusPush(NotifyBase): 'type': 'string', 'private': True, 'required': True, - 'regex': (UUID4_RE, 'i'), + 'regex': (r'^{}$'.format(UUID4_RE), 'i'), }, }) @@ -109,20 +106,15 @@ class NotifyTechulusPush(NotifyBase): """ super(NotifyTechulusPush, self).__init__(**kwargs) - if not apikey: - msg = 'The Techulus Push apikey is not specified.' - self.logger.warning(msg) - raise TypeError(msg) - - if not VALIDATE_APIKEY.match(apikey.strip()): - msg = 'The Techulus Push apikey specified ({}) is invalid.'\ - .format(apikey) + # The apikey associated with the account + self.apikey = validate_regex( + apikey, *self.template_tokens['apikey']['regex']) + if not self.apikey: + msg = 'An invalid Techulus Push API key ' \ + '({}) was specified.'.format(apikey) self.logger.warning(msg) raise TypeError(msg) - # The apikey associated with the account - self.apikey = apikey.strip() - def send(self, body, title='', notify_type=NotifyType.INFO, **kwargs): """ Perform Techulus Push Notification @@ -188,7 +180,7 @@ class NotifyTechulusPush(NotifyBase): return True - def url(self): + def url(self, privacy=False, *args, **kwargs): """ Returns the URL built dynamically based on specified arguments. """ @@ -202,7 +194,7 @@ class NotifyTechulusPush(NotifyBase): return '{schema}://{apikey}/?{args}'.format( schema=self.secure_protocol, - apikey=NotifyTechulusPush.quote(self.apikey, safe=''), + apikey=self.pprint(self.apikey, privacy, safe=''), args=NotifyTechulusPush.urlencode(args), ) diff --git a/libs/apprise/plugins/NotifyTelegram.py b/libs/apprise/plugins/NotifyTelegram.py index 2ce0ddc9f..11bfe3e78 100644 --- a/libs/apprise/plugins/NotifyTelegram.py +++ b/libs/apprise/plugins/NotifyTelegram.py @@ -51,6 +51,7 @@ # - https://core.telegram.org/bots/api import requests import re +import os from json import loads from json import dumps @@ -61,17 +62,12 @@ from ..common import NotifyImageSize from ..common import NotifyFormat from ..utils import parse_bool from ..utils import parse_list +from ..utils import validate_regex from ..AppriseLocale import gettext_lazy as _ +from ..attachment.AttachBase import AttachBase TELEGRAM_IMAGE_XY = NotifyImageSize.XY_256 -# Token required as part of the API request -# allow the word 'bot' infront -VALIDATE_BOT_TOKEN = re.compile( - r'^(bot)?(?P<key>[0-9]+:[a-z0-9_-]+)/*$', - re.IGNORECASE, -) - # Chat ID is required # If the Chat ID is positive, then it's addressed to a single person # If the Chat ID is negative, then it's targeting a group @@ -106,12 +102,71 @@ class NotifyTelegram(NotifyBase): # The maximum allowable characters allowed in the body per message body_maxlen = 4096 + # Telegram is limited to sending a maximum of 100 requests per second. + request_rate_per_sec = 0.001 + # Define object templates templates = ( '{schema}://{bot_token}', '{schema}://{bot_token}/{targets}', ) + # Telegram Attachment Support + mime_lookup = ( + # This list is intentionally ordered so that it can be scanned + # from top to bottom. The last entry is a catch-all + + # Animations are documented to only support gif or H.264/MPEG-4 + # Source: https://core.telegram.org/bots/api#sendanimation + { + 'regex': re.compile(r'^(image/gif|video/H264)', re.I), + 'function_name': 'sendAnimation', + 'key': 'animation', + }, + + # This entry is intentially placed below the sendAnimiation allowing + # it to catch gif files. This then becomes a catch all to remaining + # image types. + # Source: https://core.telegram.org/bots/api#sendphoto + { + 'regex': re.compile(r'^image/.*', re.I), + 'function_name': 'sendPhoto', + 'key': 'photo', + }, + + # Video is documented to only support .mp4 + # Source: https://core.telegram.org/bots/api#sendvideo + { + 'regex': re.compile(r'^video/mp4', re.I), + 'function_name': 'sendVideo', + 'key': 'video', + }, + + # Voice supports ogg + # Source: https://core.telegram.org/bots/api#sendvoice + { + 'regex': re.compile(r'^(application|audio)/ogg', re.I), + 'function_name': 'sendVoice', + 'key': 'voice', + }, + + # Audio supports mp3 and m4a only + # Source: https://core.telegram.org/bots/api#sendaudio + { + 'regex': re.compile(r'^audio/(mpeg|mp4a-latm)', re.I), + 'function_name': 'sendAudio', + 'key': 'audio', + }, + + # Catch All (all other types) + # Source: https://core.telegram.org/bots/api#senddocument + { + 'regex': re.compile(r'.*', re.I), + 'function_name': 'sendDocument', + 'key': 'document', + }, + ) + # Define our template tokens template_tokens = dict(NotifyBase.template_tokens, **{ 'bot_token': { @@ -119,14 +174,16 @@ class NotifyTelegram(NotifyBase): 'type': 'string', 'private': True, 'required': True, - 'regex': (r'(bot)?[0-9]+:[a-z0-9_-]+', 'i'), + # Token required as part of the API request, allow the word 'bot' + # infront of it + 'regex': (r'^(bot)?(?P<key>[0-9]+:[a-z0-9_-]+)$', 'i'), }, 'target_user': { 'name': _('Target Chat ID'), 'type': 'string', 'map_to': 'targets', 'map_to': 'targets', - 'regex': (r'((-?[0-9]{1,32})|([a-z_-][a-z0-9_-]+))', 'i'), + 'regex': (r'^((-?[0-9]{1,32})|([a-z_-][a-z0-9_-]+))$', 'i'), }, 'targets': { 'name': _('Targets'), @@ -160,24 +217,15 @@ class NotifyTelegram(NotifyBase): """ super(NotifyTelegram, self).__init__(**kwargs) - try: - self.bot_token = bot_token.strip() - - except AttributeError: - # Token was None - err = 'No Bot Token was specified.' - self.logger.warning(err) - raise TypeError(err) - - result = VALIDATE_BOT_TOKEN.match(self.bot_token) - if not result: - err = 'The Bot Token specified (%s) is invalid.' % bot_token + self.bot_token = validate_regex( + bot_token, *self.template_tokens['bot_token']['regex'], + fmt='{key}') + if not self.bot_token: + err = 'The Telegram Bot Token specified ({}) is invalid.'.format( + bot_token) self.logger.warning(err) raise TypeError(err) - # Store our Bot Token - self.bot_token = result.group('key') - # Parse our list self.targets = parse_list(targets) @@ -202,82 +250,101 @@ class NotifyTelegram(NotifyBase): # or not. self.include_image = include_image - def send_image(self, chat_id, notify_type): + def send_media(self, chat_id, notify_type, attach=None): """ Sends a sticker based on the specified notify type """ - # The URL; we do not set headers because the api doesn't seem to like - # when we set one. + # Prepare our Headers + headers = { + 'User-Agent': self.app_id, + } + + # Our function name and payload are determined on the path + function_name = 'SendPhoto' + key = 'photo' + path = None + + if isinstance(attach, AttachBase): + # Store our path to our file + path = attach.path + file_name = attach.name + mimetype = attach.mimetype + + if not path: + # Could not load attachment + return False + + # Process our attachment + function_name, key = \ + next(((x['function_name'], x['key']) for x in self.mime_lookup + if x['regex'].match(mimetype))) # pragma: no cover + + else: + attach = self.image_path(notify_type) if attach is None else attach + if attach is None: + # Nothing specified to send + return True + + # Take on specified attachent as path + path = attach + file_name = os.path.basename(path) + url = '%s%s/%s' % ( self.notify_url, self.bot_token, - 'sendPhoto' + function_name, ) - # Acquire our image path if configured to do so; we don't bother - # checking to see if selfinclude_image is set here because the - # send_image() function itself (this function) checks this flag - # already - path = self.image_path(notify_type) - - if not path: - # No image to send - self.logger.debug( - 'Telegram image does not exist for %s' % (notify_type)) - - # No need to fail; we may have been configured this way through - # the apprise.AssetObject() - return True + # Always call throttle before any remote server i/o is made; + # Telegram throttles to occur before sending the image so that + # content can arrive together. + self.throttle() try: with open(path, 'rb') as f: # Configure file payload (for upload) - files = { - 'photo': f, - } - - payload = { - 'chat_id': chat_id, - } + files = {key: (file_name, f)} + payload = {'chat_id': chat_id} self.logger.debug( - 'Telegram image POST URL: %s (cert_verify=%r)' % ( + 'Telegram attachment POST URL: %s (cert_verify=%r)' % ( url, self.verify_certificate)) - try: - r = requests.post( - url, - files=files, - data=payload, - verify=self.verify_certificate, - ) - - if r.status_code != requests.codes.ok: - # We had a problem - status_str = NotifyTelegram\ - .http_response_code_lookup(r.status_code) + r = requests.post( + url, + headers=headers, + files=files, + data=payload, + verify=self.verify_certificate, + ) - self.logger.warning( - 'Failed to send Telegram image: ' - '{}{}error={}.'.format( - status_str, - ', ' if status_str else '', - r.status_code)) + if r.status_code != requests.codes.ok: + # We had a problem + status_str = NotifyTelegram\ + .http_response_code_lookup(r.status_code) - self.logger.debug( - 'Response Details:\r\n{}'.format(r.content)) + self.logger.warning( + 'Failed to send Telegram attachment: ' + '{}{}error={}.'.format( + status_str, + ', ' if status_str else '', + r.status_code)) - return False + self.logger.debug( + 'Response Details:\r\n{}'.format(r.content)) - except requests.RequestException as e: - self.logger.warning( - 'A connection error occured posting Telegram image.') - self.logger.debug('Socket Exception: %s' % str(e)) return False - return True + # Content was sent successfully if we got here + return True + + except requests.RequestException as e: + self.logger.warning( + 'A connection error occured posting Telegram ' + 'attachment.') + self.logger.debug('Socket Exception: %s' % str(e)) except (IOError, OSError): # IOError is present for backwards compatibility with Python @@ -311,6 +378,9 @@ class NotifyTelegram(NotifyBase): 'Telegram User Detection POST URL: %s (cert_verify=%r)' % ( url, self.verify_certificate)) + # Track our response object + response = None + try: r = requests.post( url, @@ -325,9 +395,12 @@ class NotifyTelegram(NotifyBase): try: # Try to get the error message if we can: - error_msg = loads(r.content)['description'] + error_msg = loads(r.content).get('description', 'unknown') - except Exception: + except (AttributeError, TypeError, ValueError): + # ValueError = r.content is Unparsable + # TypeError = r.content is None + # AttributeError = r is None error_msg = None if error_msg: @@ -347,6 +420,18 @@ class NotifyTelegram(NotifyBase): return 0 + # Load our response and attempt to fetch our userid + response = loads(r.content) + + except (AttributeError, TypeError, ValueError): + # Our response was not the JSON type we had expected it to be + # - ValueError = r.content is Unparsable + # - TypeError = r.content is None + # - AttributeError = r is None + self.logger.warning( + 'A communication error occured detecting the Telegram User.') + return 0 + except requests.RequestException as e: self.logger.warning( 'A connection error occured detecting the Telegram User.') @@ -375,28 +460,20 @@ class NotifyTelegram(NotifyBase): # "text":"/start", # "entities":[{"offset":0,"length":6,"type":"bot_command"}]}}] - # Load our response and attempt to fetch our userid - response = loads(r.content) - if 'ok' in response and response['ok'] is True: - start = re.compile(r'^\s*\/start', re.I) - for _msg in iter(response['result']): - # Find /start - if not start.search(_msg['message']['text']): - continue - - _id = _msg['message']['from'].get('id', 0) - _user = _msg['message']['from'].get('first_name') - self.logger.info('Detected telegram user %s (userid=%d)' % ( - _user, _id)) - # Return our detected userid - return _id - - self.logger.warning( - 'Could not detect bot owner. Is it running (/start)?') + if 'ok' in response and response['ok'] is True \ + and 'result' in response and len(response['result']): + entry = response['result'][0] + _id = entry['message']['from'].get('id', 0) + _user = entry['message']['from'].get('first_name') + self.logger.info('Detected telegram user %s (userid=%d)' % ( + _user, _id)) + # Return our detected userid + return _id return 0 - def send(self, body, title='', notify_type=NotifyType.INFO, **kwargs): + def send(self, body, title='', notify_type=NotifyType.INFO, attach=None, + **kwargs): """ Perform Telegram Notification """ @@ -489,15 +566,20 @@ class NotifyTelegram(NotifyBase): # ID payload['chat_id'] = int(chat_id.group('idno')) + if self.include_image is True: + # Define our path + if not self.send_media(payload['chat_id'], notify_type): + # We failed to send the image associated with our + notify_type + self.logger.warning( + 'Failed to send Telegram type image to {}.', + payload['chat_id']) + # Always call throttle before any remote server i/o is made; # Telegram throttles to occur before sending the image so that # content can arrive together. self.throttle() - if self.include_image is True: - # Send an image - self.send_image(payload['chat_id'], notify_type) - self.logger.debug('Telegram POST URL: %s (cert_verify=%r)' % ( url, self.verify_certificate, )) @@ -518,9 +600,13 @@ class NotifyTelegram(NotifyBase): try: # Try to get the error message if we can: - error_msg = loads(r.content)['description'] + error_msg = loads(r.content).get( + 'description', 'unknown') - except Exception: + except (AttributeError, TypeError, ValueError): + # ValueError = r.content is Unparsable + # TypeError = r.content is None + # AttributeError = r is None error_msg = None self.logger.warning( @@ -537,9 +623,6 @@ class NotifyTelegram(NotifyBase): has_error = True continue - else: - self.logger.info('Sent Telegram notification.') - except requests.RequestException as e: self.logger.warning( 'A connection error occured sending Telegram:%s ' % ( @@ -551,9 +634,25 @@ class NotifyTelegram(NotifyBase): has_error = True continue + self.logger.info('Sent Telegram notification.') + + if attach: + # Send our attachments now (if specified and if it exists) + for attachment in attach: + sent_attachment = self.send_media( + payload['chat_id'], notify_type, attach=attachment) + + if not sent_attachment: + # We failed; don't continue + has_error = True + break + + self.logger.info( + 'Sent Telegram attachment: {}.'.format(attachment)) + return not has_error - def url(self): + def url(self, privacy=False, *args, **kwargs): """ Returns the URL built dynamically based on specified arguments. """ @@ -571,7 +670,7 @@ class NotifyTelegram(NotifyBase): # appended into the list of chat ids return '{schema}://{bot_token}/{targets}/?{args}'.format( schema=self.secure_protocol, - bot_token=NotifyTelegram.quote(self.bot_token, safe=''), + bot_token=self.pprint(self.bot_token, privacy, safe=''), targets='/'.join( [NotifyTelegram.quote('@{}'.format(x)) for x in self.targets]), args=NotifyTelegram.urlencode(args)) diff --git a/libs/apprise/plugins/NotifyTwilio.py b/libs/apprise/plugins/NotifyTwilio.py index 2bb1042bb..ec78e46ea 100644 --- a/libs/apprise/plugins/NotifyTwilio.py +++ b/libs/apprise/plugins/NotifyTwilio.py @@ -45,15 +45,13 @@ 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 validate_regex from ..AppriseLocale import gettext_lazy as _ -# Used to validate your personal access apikey -VALIDATE_AUTH_TOKEN = re.compile(r'^[a-f0-9]{32}$', re.I) -VALIDATE_ACCOUNT_SID = re.compile(r'^AC[a-f0-9]{32}$', re.I) - # Some Phone Number Detection IS_PHONE_NO = re.compile(r'^\+?(?P<phone>[0-9\s)(+-]+)\s*$') @@ -107,33 +105,33 @@ class NotifyTwilio(NotifyBase): 'type': 'string', 'private': True, 'required': True, - 'regex': (r'AC[a-f0-9]{32}', 'i'), + 'regex': (r'^AC[a-f0-9]+$', 'i'), }, 'auth_token': { 'name': _('Auth Token'), 'type': 'string', 'private': True, 'required': True, - 'regex': (r'[a-f0-9]{32}', 'i'), + 'regex': (r'^[a-f0-9]+$', 'i'), }, 'from_phone': { 'name': _('From Phone No'), 'type': 'string', 'required': True, - 'regex': (r'\+?[0-9\s)(+-]+', 'i'), + 'regex': (r'^\+?[0-9\s)(+-]+$', 'i'), 'map_to': 'source', }, 'target_phone': { 'name': _('Target Phone No'), 'type': 'string', 'prefix': '+', - 'regex': (r'[0-9\s)(+-]+', 'i'), + 'regex': (r'^[0-9\s)(+-]+$', 'i'), 'map_to': 'targets', }, 'short_code': { 'name': _('Target Short Code'), 'type': 'string', - 'regex': (r'[0-9]{5,6}', 'i'), + 'regex': (r'^[0-9]{5,6}$', 'i'), 'map_to': 'targets', }, 'targets': { @@ -165,35 +163,21 @@ class NotifyTwilio(NotifyBase): """ super(NotifyTwilio, self).__init__(**kwargs) - try: - # The Account SID associated with the account - self.account_sid = account_sid.strip() - - except AttributeError: - # Token was None - msg = 'No Account SID was specified.' - self.logger.warning(msg) - raise TypeError(msg) - - if not VALIDATE_ACCOUNT_SID.match(self.account_sid): - msg = 'The Account SID specified ({}) is invalid.' \ - .format(account_sid) + # The Account SID associated with the account + self.account_sid = validate_regex( + account_sid, *self.template_tokens['account_sid']['regex']) + if not self.account_sid: + msg = 'An invalid Twilio Account SID ' \ + '({}) was specified.'.format(account_sid) self.logger.warning(msg) raise TypeError(msg) - try: - # The authentication token associated with the account - self.auth_token = auth_token.strip() - - except AttributeError: - # Token was None - msg = 'No Auth Token was specified.' - self.logger.warning(msg) - raise TypeError(msg) - - if not VALIDATE_AUTH_TOKEN.match(self.auth_token): - msg = 'The Auth Token specified ({}) is invalid.' \ - .format(auth_token) + # The Authentication Token associated with the account + self.auth_token = validate_regex( + auth_token, *self.template_tokens['auth_token']['regex']) + if not self.auth_token: + msg = 'An invalid Twilio Authentication Token ' \ + '({}) was specified.'.format(auth_token) self.logger.warning(msg) raise TypeError(msg) @@ -253,14 +237,16 @@ class NotifyTwilio(NotifyBase): '({}) specified.'.format(target), ) - if len(self.targets) == 0: - msg = 'There are no valid targets identified to notify.' + 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) + return + def send(self, body, title='', notify_type=NotifyType.INFO, **kwargs): """ Perform Twilio Notification @@ -335,11 +321,13 @@ class NotifyTwilio(NotifyBase): status_code = json_response.get('code', status_code) 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( @@ -374,7 +362,7 @@ class NotifyTwilio(NotifyBase): return not has_error - def url(self): + def url(self, privacy=False, *args, **kwargs): """ Returns the URL built dynamically based on specified arguments. """ @@ -388,8 +376,9 @@ class NotifyTwilio(NotifyBase): return '{schema}://{sid}:{token}@{source}/{targets}/?{args}'.format( schema=self.secure_protocol, - sid=self.account_sid, - token=self.auth_token, + sid=self.pprint( + self.account_sid, privacy, mode=PrivacyMode.Tail, safe=''), + token=self.pprint(self.auth_token, privacy, safe=''), source=NotifyTwilio.quote(self.source, safe=''), targets='/'.join( [NotifyTwilio.quote(x, safe='') for x in self.targets]), diff --git a/libs/apprise/plugins/NotifyTwist.py b/libs/apprise/plugins/NotifyTwist.py index 1c15ce941..0aafe18a2 100644 --- a/libs/apprise/plugins/NotifyTwist.py +++ b/libs/apprise/plugins/NotifyTwist.py @@ -32,6 +32,7 @@ from json import loads from itertools import chain from .NotifyBase import NotifyBase +from ..URLBase import PrivacyMode from ..common import NotifyFormat from ..common import NotifyType from ..utils import parse_list @@ -223,7 +224,7 @@ class NotifyTwist(NotifyBase): self.default_notification_channel)) return - def url(self): + def url(self, privacy=False, *args, **kwargs): """ Returns the URL built dynamically based on specified arguments. """ @@ -237,7 +238,8 @@ class NotifyTwist(NotifyBase): return '{schema}://{password}:{user}@{host}/{targets}/?{args}'.format( schema=self.secure_protocol, - password=self.quote(self.password, safe=''), + password=self.pprint( + self.password, privacy, mode=PrivacyMode.Secret, safe=''), user=self.quote(self.user, safe=''), host=self.host, targets='/'.join( diff --git a/libs/apprise/plugins/NotifyTwitter.py b/libs/apprise/plugins/NotifyTwitter.py index 2ecd61332..f6e57624a 100644 --- a/libs/apprise/plugins/NotifyTwitter.py +++ b/libs/apprise/plugins/NotifyTwitter.py @@ -33,9 +33,11 @@ from requests_oauthlib import OAuth1 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 +from ..utils import validate_regex from ..AppriseLocale import gettext_lazy as _ IS_USER = re.compile(r'^\s*@?(?P<user>[A-Z0-9_]+)$', re.I) @@ -185,23 +187,27 @@ class NotifyTwitter(NotifyBase): """ super(NotifyTwitter, self).__init__(**kwargs) - if not ckey: - msg = 'An invalid Consumer API Key was specified.' + self.ckey = validate_regex(ckey) + if not self.ckey: + msg = 'An invalid Twitter Consumer Key was specified.' self.logger.warning(msg) raise TypeError(msg) - if not csecret: - msg = 'An invalid Consumer Secret API Key was specified.' + self.csecret = validate_regex(csecret) + if not self.csecret: + msg = 'An invalid Twitter Consumer Secret was specified.' self.logger.warning(msg) raise TypeError(msg) - if not akey: - msg = 'An invalid Access Token API Key was specified.' + self.akey = validate_regex(akey) + if not self.akey: + msg = 'An invalid Twitter Access Key was specified.' self.logger.warning(msg) raise TypeError(msg) - if not asecret: - msg = 'An invalid Access Token Secret API Key was specified.' + self.asecret = validate_regex(asecret) + if not self.asecret: + msg = 'An invalid Access Secret was specified.' self.logger.warning(msg) raise TypeError(msg) @@ -218,6 +224,9 @@ class NotifyTwitter(NotifyBase): self.logger.warning(msg) raise TypeError(msg) + # Track any errors + has_error = False + # Identify our targets self.targets = [] for target in parse_list(targets): @@ -226,15 +235,19 @@ class NotifyTwitter(NotifyBase): self.targets.append(match.group('user')) continue + has_error = True self.logger.warning( 'Dropped invalid user ({}) specified.'.format(target), ) - # Store our data - self.ckey = ckey - self.csecret = csecret - self.akey = akey - self.asecret = asecret + if has_error and not self.targets: + # We have specified that we want to notify one or more individual + # and we failed to load any of them. Since it's also valid to + # notify no one at all (which means we notify ourselves), it's + # important we don't switch from the users original intentions + msg = 'No Twitter targets to notify.' + self.logger.warning(msg) + raise TypeError(msg) return @@ -296,7 +309,7 @@ class NotifyTwitter(NotifyBase): } } - # Lookup our users + # Lookup our users (otherwise we look up ourselves) targets = self._whoami(lazy=self.cache) if not len(self.targets) \ else self._user_lookup(self.targets, lazy=self.cache) @@ -521,9 +534,10 @@ class NotifyTwitter(NotifyBase): try: content = loads(r.content) - except (TypeError, ValueError): + except (AttributeError, TypeError, ValueError): # ValueError = r.content is Unparsable # TypeError = r.content is None + # AttributeError = r is None content = {} try: @@ -558,7 +572,7 @@ class NotifyTwitter(NotifyBase): """ return 10000 if self.mode == TwitterMessageMode.DM else 280 - def url(self): + def url(self, privacy=False, *args, **kwargs): """ Returns the URL built dynamically based on specified arguments. """ @@ -578,10 +592,12 @@ class NotifyTwitter(NotifyBase): return '{schema}://{ckey}/{csecret}/{akey}/{asecret}' \ '/{targets}/?{args}'.format( schema=self.secure_protocol[0], - ckey=NotifyTwitter.quote(self.ckey, safe=''), - asecret=NotifyTwitter.quote(self.csecret, safe=''), - akey=NotifyTwitter.quote(self.akey, safe=''), - csecret=NotifyTwitter.quote(self.asecret, safe=''), + ckey=self.pprint(self.ckey, privacy, safe=''), + csecret=self.pprint( + self.csecret, privacy, mode=PrivacyMode.Secret, safe=''), + akey=self.pprint(self.akey, privacy, safe=''), + asecret=self.pprint( + self.asecret, privacy, mode=PrivacyMode.Secret, safe=''), targets='/'.join( [NotifyTwitter.quote('@{}'.format(target), safe='') for target in self.targets]), diff --git a/libs/apprise/plugins/NotifyWebexTeams.py b/libs/apprise/plugins/NotifyWebexTeams.py index 687ff0b01..35d4ffbee 100644 --- a/libs/apprise/plugins/NotifyWebexTeams.py +++ b/libs/apprise/plugins/NotifyWebexTeams.py @@ -63,11 +63,9 @@ from json import dumps from .NotifyBase import NotifyBase from ..common import NotifyType from ..common import NotifyFormat +from ..utils import validate_regex from ..AppriseLocale import gettext_lazy as _ -# Token required as part of the API request -VALIDATE_TOKEN = re.compile(r'[a-z0-9]{80}', re.I) - # Extend HTTP Error Messages # Based on: https://developer.webex.com/docs/api/basics/rate-limiting WEBEX_HTTP_ERROR_MAP = { @@ -119,7 +117,7 @@ class NotifyWebexTeams(NotifyBase): 'type': 'string', 'private': True, 'required': True, - 'regex': (r'[a-z0-9]{80}', 'i'), + 'regex': (r'^[a-z0-9]{80}$', 'i'), }, }) @@ -129,20 +127,15 @@ class NotifyWebexTeams(NotifyBase): """ super(NotifyWebexTeams, self).__init__(**kwargs) - if not token: - msg = 'The Webex Teams token is not specified.' - self.logger.warning(msg) - raise TypeError(msg) - - if not VALIDATE_TOKEN.match(token.strip()): + # The token associated with the account + self.token = validate_regex( + token, *self.template_tokens['token']['regex']) + if not self.token: msg = 'The Webex Teams token specified ({}) is invalid.'\ .format(token) self.logger.warning(msg) raise TypeError(msg) - # The token associated with the account - self.token = token.strip() - def send(self, body, title='', notify_type=NotifyType.INFO, **kwargs): """ Perform Webex Teams Notification @@ -210,7 +203,7 @@ class NotifyWebexTeams(NotifyBase): return True - def url(self): + def url(self, privacy=False, *args, **kwargs): """ Returns the URL built dynamically based on specified arguments. """ @@ -224,7 +217,7 @@ class NotifyWebexTeams(NotifyBase): return '{schema}://{token}/?{args}'.format( schema=self.secure_protocol, - token=NotifyWebexTeams.quote(self.token, safe=''), + token=self.pprint(self.token, privacy, safe=''), args=NotifyWebexTeams.urlencode(args), ) @@ -255,7 +248,7 @@ class NotifyWebexTeams(NotifyBase): result = re.match( r'^https?://api\.ciscospark\.com/v[1-9][0-9]*/webhooks/incoming/' r'(?P<webhook_token>[A-Z0-9_-]+)/?' - r'(?P<args>\?[.+])?$', url, re.I) + r'(?P<args>\?.+)?$', url, re.I) if result: return NotifyWebexTeams.parse_url( diff --git a/libs/apprise/plugins/NotifyWindows.py b/libs/apprise/plugins/NotifyWindows.py index 257324d3d..50e7e60ae 100644 --- a/libs/apprise/plugins/NotifyWindows.py +++ b/libs/apprise/plugins/NotifyWindows.py @@ -217,7 +217,7 @@ class NotifyWindows(NotifyBase): return True - def url(self): + def url(self, privacy=False, *args, **kwargs): """ Returns the URL built dynamically based on specified arguments. """ diff --git a/libs/apprise/plugins/NotifyXBMC.py b/libs/apprise/plugins/NotifyXBMC.py index 3b29930b3..d286ac60e 100644 --- a/libs/apprise/plugins/NotifyXBMC.py +++ b/libs/apprise/plugins/NotifyXBMC.py @@ -27,6 +27,7 @@ import requests from json import dumps from .NotifyBase import NotifyBase +from ..URLBase import PrivacyMode from ..common import NotifyType from ..common import NotifyImageSize from ..utils import parse_bool @@ -296,7 +297,7 @@ class NotifyXBMC(NotifyBase): return True - def url(self): + def url(self, privacy=False, *args, **kwargs): """ Returns the URL built dynamically based on specified arguments. """ @@ -315,7 +316,8 @@ class NotifyXBMC(NotifyBase): if self.user and self.password: auth = '{user}:{password}@'.format( user=NotifyXBMC.quote(self.user, safe=''), - password=NotifyXBMC.quote(self.password, safe=''), + password=self.pprint( + self.password, privacy, mode=PrivacyMode.Secret, safe=''), ) elif self.user: auth = '{user}@'.format( @@ -327,7 +329,7 @@ class NotifyXBMC(NotifyBase): default_port = 443 if self.secure else self.xbmc_default_port if self.secure: # Append 's' to schema - default_schema + 's' + default_schema += 's' return '{schema}://{auth}{hostname}{port}/?{args}'.format( schema=default_schema, diff --git a/libs/apprise/plugins/NotifyXML.py b/libs/apprise/plugins/NotifyXML.py index f262200b7..340446c1e 100644 --- a/libs/apprise/plugins/NotifyXML.py +++ b/libs/apprise/plugins/NotifyXML.py @@ -28,6 +28,7 @@ import six import requests from .NotifyBase import NotifyBase +from ..URLBase import PrivacyMode from ..common import NotifyImageSize from ..common import NotifyType from ..AppriseLocale import gettext_lazy as _ @@ -58,7 +59,6 @@ class NotifyXML(NotifyBase): request_rate_per_sec = 0 # Define object templates - # Define object templates templates = ( '{schema}://{host}', '{schema}://{host}:{port}', @@ -138,7 +138,7 @@ class NotifyXML(NotifyBase): return - def url(self): + def url(self, privacy=False, *args, **kwargs): """ Returns the URL built dynamically based on specified arguments. """ @@ -158,7 +158,8 @@ class NotifyXML(NotifyBase): if self.user and self.password: auth = '{user}:{password}@'.format( user=NotifyXML.quote(self.user, safe=''), - password=NotifyXML.quote(self.password, safe=''), + password=self.pprint( + self.password, privacy, mode=PrivacyMode.Secret, safe=''), ) elif self.user: auth = '{user}@'.format( @@ -167,12 +168,13 @@ class NotifyXML(NotifyBase): 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=NotifyXML.quote(self.host, safe=''), port='' if self.port is None or self.port == default_port else ':{}'.format(self.port), + fullpath=NotifyXML.quote(self.fullpath, safe='/'), args=NotifyXML.urlencode(args), ) diff --git a/libs/apprise/plugins/NotifyXMPP.py b/libs/apprise/plugins/NotifyXMPP.py index 6fc821960..82623cb45 100644 --- a/libs/apprise/plugins/NotifyXMPP.py +++ b/libs/apprise/plugins/NotifyXMPP.py @@ -28,6 +28,7 @@ import ssl from os.path import isfile from .NotifyBase import NotifyBase +from ..URLBase import PrivacyMode from ..common import NotifyType from ..utils import parse_list from ..AppriseLocale import gettext_lazy as _ @@ -156,7 +157,7 @@ class NotifyXMPP(NotifyBase): 'name': _('XEP'), 'type': 'list:string', 'prefix': 'xep-', - 'regex': (r'[1-9][0-9]{0,3}', 'i'), + 'regex': (r'^[1-9][0-9]{0,3}$', 'i'), }, 'jid': { 'name': _('Source JID'), @@ -344,7 +345,7 @@ class NotifyXMPP(NotifyBase): return True - def url(self): + def url(self, privacy=False, *args, **kwargs): """ Returns the URL built dynamically based on specified arguments. """ @@ -374,12 +375,15 @@ class NotifyXMPP(NotifyBase): default_schema = self.secure_protocol if self.secure else self.protocol if self.user and self.password: - auth = '{}:{}'.format( - NotifyXMPP.quote(self.user, safe=''), - NotifyXMPP.quote(self.password, safe='')) + auth = '{user}:{password}'.format( + user=NotifyXMPP.quote(self.user, safe=''), + password=self.pprint( + self.password, privacy, mode=PrivacyMode.Secret, safe='')) else: - auth = self.password if self.password else self.user + auth = self.pprint( + self.password if self.password else self.user, privacy, + mode=PrivacyMode.Secret, safe='') return '{schema}://{auth}@{hostname}{port}/{jids}?{args}'.format( auth=auth, diff --git a/libs/apprise/plugins/NotifyZulip.py b/libs/apprise/plugins/NotifyZulip.py index 376f4cdc5..00024218f 100644 --- a/libs/apprise/plugins/NotifyZulip.py +++ b/libs/apprise/plugins/NotifyZulip.py @@ -61,15 +61,13 @@ import requests from .NotifyBase import NotifyBase from ..common import NotifyType from ..utils import parse_list +from ..utils import validate_regex from ..utils import GET_EMAIL_RE from ..AppriseLocale import gettext_lazy as _ # A Valid Bot Name VALIDATE_BOTNAME = re.compile(r'(?P<name>[A-Z0-9_]{1,32})(-bot)?', re.I) -# A Valid Bot Token is 32 characters of alpha/numeric -VALIDATE_TOKEN = re.compile(r'[A-Z0-9]{32}', re.I) - # Organization required as part of the API request VALIDATE_ORG = re.compile( r'(?P<org>[A-Z0-9_-]{1,32})(\.(?P<hostname>[^\s]+))?', re.I) @@ -124,18 +122,20 @@ class NotifyZulip(NotifyBase): 'botname': { 'name': _('Bot Name'), 'type': 'string', + 'regex': (r'^[A-Z0-9_]{1,32}(-bot)?$', 'i'), }, 'organization': { 'name': _('Organization'), 'type': 'string', 'required': True, + 'regex': (r'^[A-Z0-9_-]{1,32})$', 'i') }, 'token': { 'name': _('Token'), 'type': 'string', 'required': True, 'private': True, - 'regex': (r'[A-Z0-9]{32}', 'i'), + 'regex': (r'^[A-Z0-9]{32}$', 'i'), }, 'target_user': { 'name': _('Target User'), @@ -208,20 +208,14 @@ class NotifyZulip(NotifyBase): self.logger.warning(msg) raise TypeError(msg) - try: - if not VALIDATE_TOKEN.match(token.strip()): - # let outer exception handle this - raise TypeError - - except (TypeError, AttributeError): + self.token = validate_regex( + token, *self.template_tokens['token']['regex']) + if not self.token: msg = 'The Zulip token specified ({}) is invalid.'\ .format(token) self.logger.warning(msg) raise TypeError(msg) - # The token associated with the account - self.token = token.strip() - self.targets = parse_list(targets) if len(self.targets) == 0: # No channels identified, use default @@ -328,7 +322,7 @@ class NotifyZulip(NotifyBase): return not has_error - def url(self): + def url(self, privacy=False, *args, **kwargs): """ Returns the URL built dynamically based on specified arguments. """ @@ -349,9 +343,9 @@ class NotifyZulip(NotifyBase): return '{schema}://{botname}@{org}/{token}/' \ '{targets}?{args}'.format( schema=self.secure_protocol, - botname=self.botname, + botname=NotifyZulip.quote(self.botname, safe=''), org=NotifyZulip.quote(organization, safe=''), - token=NotifyZulip.quote(self.token, safe=''), + token=self.pprint(self.token, privacy, safe=''), targets='/'.join( [NotifyZulip.quote(x, safe='') for x in self.targets]), args=NotifyZulip.urlencode(args), diff --git a/libs/apprise/plugins/__init__.py b/libs/apprise/plugins/__init__.py index ec6780a6a..f8728a9da 100644 --- a/libs/apprise/plugins/__init__.py +++ b/libs/apprise/plugins/__init__.py @@ -33,9 +33,6 @@ from os.path import abspath # Used for testing from . import NotifyEmail as NotifyEmailBase - -# Required until re-factored into base code -from .NotifyPushjet import pushjet from .NotifyGrowl import gntp # NotifyBase object is passed in as a module not class @@ -46,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 GET_SCHEMA_RE from ..AppriseLocale import gettext_lazy as _ from ..AppriseLocale import LazyTranslation @@ -60,11 +58,11 @@ __all__ = [ # NotifyEmail Base Module (used for NotifyEmail testing) 'NotifyEmailBase', + # Tokenizer + 'url_to_dict', + # gntp (used for NotifyGrowl Testing) 'gntp', - - # pushjet (used for NotifyPushjet Testing) - 'pushjet', ] # we mirror our base purely for the ability to reset everything; this @@ -384,6 +382,16 @@ def details(plugin): # Argument/Option Handling for key in list(template_args.keys()): + if 'alias_of' in template_args[key]: + # Check if the mapped reference is a list; if it is, then + # we need to store a different delimiter + alias_of = template_tokens.get(template_args[key]['alias_of'], {}) + if alias_of.get('type', '').startswith('list') \ + and 'delim' not in template_args[key]: + # Set a default delimiter of a comma and/or space if one + # hasn't already been specified + template_args[key]['delim'] = (',', ' ') + # _lookup_default looks up what the default value if '_lookup_default' in template_args[key]: template_args[key]['default'] = getattr( @@ -410,3 +418,47 @@ def details(plugin): 'args': template_args, 'kwargs': template_kwargs, } + + +def url_to_dict(url): + """ + Takes an apprise URL and returns the tokens associated with it + if they can be acquired based on the plugins available. + + None is returned if the URL could not be parsed, otherwise the + tokens are returned. + + These tokens can be loaded into apprise through it's add() + function. + """ + + # 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: + # Not a valid URL; take an early exit + return None + + # Ensure our schema is always in lower case + schema = schema.group('schema').lower() + if schema not in 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 the + # native_url() parse function + results = \ + next((r['plugin'].parse_native_url(_url) + for r in 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 = SCHEMA_MAP[schema].parse_url(_url) + + # Return our results + return results diff --git a/libs/apprise/utils.py b/libs/apprise/utils.py index 0dcc6cb00..b1758c1e5 100644 --- a/libs/apprise/utils.py +++ b/libs/apprise/utils.py @@ -28,6 +28,7 @@ import six import contextlib import os from os.path import expanduser +from functools import reduce try: # Python 2.7 @@ -113,10 +114,17 @@ GET_EMAIL_RE = re.compile( re.IGNORECASE, ) +# Regular expression used to extract a phone number +GET_PHONE_NO_RE = re.compile(r'^\+?(?P<phone>[0-9\s)(+-]+)\s*$') + # Regular expression used to destinguish between multiple URLs URL_DETECTION_RE = re.compile( r'([a-z0-9]+?:\/\/.*?)[\s,]*(?=$|[a-z0-9]+?:\/\/)', re.I) +# validate_regex() utilizes this mapping to track and re-use pre-complied +# regular expressions +REGEX_VALIDATE_LOOKUP = {} + def is_hostname(hostname): """ @@ -512,14 +520,6 @@ def parse_list(*args): elif isinstance(arg, (set, list, tuple)): result += parse_list(*arg) - elif arg is None: - # Ignore - continue - - else: - # Convert whatever it is to a string and work with it - result += parse_list(str(arg)) - # # filter() eliminates any empty entries # @@ -529,7 +529,7 @@ def parse_list(*args): return sorted([x for x in filter(bool, list(set(result)))]) -def is_exclusive_match(logic, data): +def is_exclusive_match(logic, data, match_all='all'): """ The data variable should always be a set of strings that the logic can be @@ -547,21 +547,22 @@ def is_exclusive_match(logic, data): logic=[('tagB', 'tagC')] = tagB and tagC """ - if logic is None: - # If there is no logic to apply then we're done early - return True - - elif isinstance(logic, six.string_types): + if isinstance(logic, six.string_types): # Update our logic to support our delimiters logic = set(parse_list(logic)) + if not logic: + # If there is no logic to apply then we're done early; we only match + # if there is also no data to match against + return not data + if not isinstance(logic, (list, tuple, set)): # garbage input return False - # using the data detected; determine if we'll allow the - # notification to be sent or not - matched = (len(logic) == 0) + # Track what we match against; but by default we do not match + # against anything + matched = False # Every entry here will be or'ed with the next for entry in logic: @@ -572,8 +573,13 @@ def is_exclusive_match(logic, data): # treat these entries as though all elements found # must exist in the notification service entries = set(parse_list(entry)) + if not entries: + # We got a bogus set of tags to parse + # If there is no logic to apply then we're done early; we only + # match if there is also no data to match against + return not data - if len(entries.intersection(data)) == len(entries): + if len(entries.intersection(data.union({match_all}))) == len(entries): # our set contains all of the entries found # in our notification data set matched = True @@ -586,6 +592,82 @@ def is_exclusive_match(logic, data): return matched +def validate_regex(value, regex=r'[^\s]+', flags=re.I, strip=True, fmt=None): + """ + A lot of the tokens, secrets, api keys, etc all have some regular + expression validation they support. This hashes the regex after it's + compiled and returns it's content if matched, otherwise it returns None. + + This function greatly increases performance as it prevents apprise modules + from having to pre-compile all of their regular expressions. + + value is the element being tested + regex is the regular expression to be compiled and tested. By default + we extract the first chunk of code while eliminating surrounding + whitespace (if present) + + flags is the regular expression flags that should be applied + format is used to alter the response format if the regular + expression matches. You identify your format using {tags}. + Effectively nesting your ID's between {}. Consider a regex of: + '(?P<year>[0-9]{2})[0-9]+(?P<value>[A-Z])' + to which you could set your format up as '{value}-{year}'. This + would substitute the matched groups and format a response. + """ + + if flags: + # Regex String -> Flag Lookup Map + _map = { + # Ignore Case + 'i': re.I, + # Multi Line + 'm': re.M, + # Dot Matches All + 's': re.S, + # Locale Dependant + 'L': re.L, + # Unicode Matching + 'u': re.U, + # Verbose + 'x': re.X, + } + + if isinstance(flags, six.string_types): + # Convert a string of regular expression flags into their + # respected integer (expected) Python values and perform + # a bit-wise or on each match found: + flags = reduce( + lambda x, y: x | y, + [0] + [_map[f] for f in flags if f in _map]) + + else: + # Handles None/False/'' cases + flags = 0 + + # A key is used to store our compiled regular expression + key = '{}{}'.format(regex, flags) + + if key not in REGEX_VALIDATE_LOOKUP: + REGEX_VALIDATE_LOOKUP[key] = re.compile(regex, flags) + + # Perform our lookup usig our pre-compiled result + try: + result = REGEX_VALIDATE_LOOKUP[key].match(value) + if not result: + # let outer exception handle this + raise TypeError + + if fmt: + # Map our format back to our response + value = fmt.format(**result.groupdict()) + + except (TypeError, AttributeError): + return None + + # Return our response + return value.strip() if strip else value + + @contextlib.contextmanager def environ(*remove, **update): """ diff --git a/libs/version.txt b/libs/version.txt index 30de33b79..f5bd23153 100644 --- a/libs/version.txt +++ b/libs/version.txt @@ -1,4 +1,4 @@ -apprise=0.7.9 +apprise=0.8.1++ apscheduler=3.5.1 babelfish=0.5.5 backports.functools-lru-cache=1.5 |