aboutsummaryrefslogtreecommitdiffhomepage
path: root/libs
diff options
context:
space:
mode:
authorLouis Vézina <[email protected]>2019-11-24 20:15:30 -0500
committerLouis Vézina <[email protected]>2019-11-24 20:15:30 -0500
commit777913bd40e2229dab0e240c2de08071ae18d651 (patch)
tree80ad5fb00c0feff9d21fe077b9d76c6389735e76 /libs
parent19675820204dae14b52f1726a2ca315e9e592711 (diff)
downloadbazarr-777913bd40e2229dab0e240c2de08071ae18d651.tar.gz
bazarr-777913bd40e2229dab0e240c2de08071ae18d651.zip
Upgrade Apprise to fix issue with Discord notification.
Diffstat (limited to 'libs')
-rw-r--r--libs/apprise/Apprise.py269
-rw-r--r--libs/apprise/AppriseConfig.py86
-rw-r--r--libs/apprise/URLBase.py61
-rw-r--r--libs/apprise/__init__.py11
-rw-r--r--libs/apprise/cli.py69
-rw-r--r--libs/apprise/common.py4
-rw-r--r--libs/apprise/config/ConfigBase.py248
-rw-r--r--libs/apprise/config/ConfigFile.py24
-rw-r--r--libs/apprise/config/ConfigHTTP.py127
-rw-r--r--libs/apprise/config/__init__.py2
-rw-r--r--libs/apprise/i18n/en/LC_MESSAGES/apprise.mobin455 -> 0 bytes
-rw-r--r--libs/apprise/plugins/NotifyBase.py14
-rw-r--r--libs/apprise/plugins/NotifyBoxcar.py61
-rw-r--r--libs/apprise/plugins/NotifyD7Networks.py30
-rw-r--r--libs/apprise/plugins/NotifyDBus.py28
-rw-r--r--libs/apprise/plugins/NotifyDiscord.py126
-rw-r--r--libs/apprise/plugins/NotifyEmail.py170
-rw-r--r--libs/apprise/plugins/NotifyEmby.py27
-rw-r--r--libs/apprise/plugins/NotifyFaast.py6
-rw-r--r--libs/apprise/plugins/NotifyFlock.py44
-rw-r--r--libs/apprise/plugins/NotifyGitter.py46
-rw-r--r--libs/apprise/plugins/NotifyGnome.py6
-rw-r--r--libs/apprise/plugins/NotifyGotify.py20
-rw-r--r--libs/apprise/plugins/NotifyGrowl/__init__.py31
-rw-r--r--libs/apprise/plugins/NotifyIFTTT.py18
-rw-r--r--libs/apprise/plugins/NotifyJSON.py13
-rw-r--r--libs/apprise/plugins/NotifyJoin.py164
-rw-r--r--libs/apprise/plugins/NotifyMSTeams.py85
-rw-r--r--libs/apprise/plugins/NotifyMailgun.py21
-rw-r--r--libs/apprise/plugins/NotifyMatrix.py11
-rw-r--r--libs/apprise/plugins/NotifyMatterMost.py40
-rw-r--r--libs/apprise/plugins/NotifyNexmo.py62
-rw-r--r--libs/apprise/plugins/NotifyProwl.py47
-rw-r--r--libs/apprise/plugins/NotifyPushBullet.py260
-rw-r--r--libs/apprise/plugins/NotifyPushed.py73
-rw-r--r--libs/apprise/plugins/NotifyPushjet/__init__.py175
-rw-r--r--libs/apprise/plugins/NotifyPushjet/pushjet/__init__.py6
-rw-r--r--libs/apprise/plugins/NotifyPushjet/pushjet/errors.py48
-rw-r--r--libs/apprise/plugins/NotifyPushjet/pushjet/pushjet.py313
-rw-r--r--libs/apprise/plugins/NotifyPushjet/pushjet/utilities.py64
-rw-r--r--libs/apprise/plugins/NotifyPushover.py86
-rw-r--r--libs/apprise/plugins/NotifyRocketChat.py20
-rw-r--r--libs/apprise/plugins/NotifyRyver.py51
-rw-r--r--libs/apprise/plugins/NotifySNS.py55
-rw-r--r--libs/apprise/plugins/NotifySlack.py615
-rw-r--r--libs/apprise/plugins/NotifyTechulusPush.py28
-rw-r--r--libs/apprise/plugins/NotifyTelegram.py315
-rw-r--r--libs/apprise/plugins/NotifyTwilio.py77
-rw-r--r--libs/apprise/plugins/NotifyTwist.py6
-rw-r--r--libs/apprise/plugins/NotifyTwitter.py56
-rw-r--r--libs/apprise/plugins/NotifyWebexTeams.py25
-rw-r--r--libs/apprise/plugins/NotifyWindows.py2
-rw-r--r--libs/apprise/plugins/NotifyXBMC.py8
-rw-r--r--libs/apprise/plugins/NotifyXML.py10
-rw-r--r--libs/apprise/plugins/NotifyXMPP.py16
-rw-r--r--libs/apprise/plugins/NotifyZulip.py26
-rw-r--r--libs/apprise/plugins/__init__.py64
-rw-r--r--libs/apprise/utils.py118
-rw-r--r--libs/version.txt2
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'&': '&amp;',
-
- # Spaces to &nbsp; for formatting purposes since
- # multiple spaces are treated as one an this may
- # not be the callers intention
- r' ': '&nbsp;',
-
- # Tab support
- r'\t': '&nbsp;&nbsp;&nbsp;',
-
- # Greater than and Less than Characters
- r'>': '&gt;',
- r'<': '&lt;',
- }
-
- # 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'&': '&amp;',
+
+ # Spaces to &nbsp; for formatting purposes since
+ # multiple spaces are treated as one an this may
+ # not be the callers intention
+ r' ': '&nbsp;',
+
+ # Tab support
+ r'\t': '&nbsp;&nbsp;&nbsp;',
+
+ # Greater than and Less than Characters
+ r'>': '&gt;',
+ r'<': '&lt;',
+ }
+
+ # 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
deleted file mode 100644
index 0decd3509..000000000
--- a/libs/apprise/i18n/en/LC_MESSAGES/apprise.mo
+++ /dev/null
Binary files differ
diff --git a/libs/apprise/plugins/NotifyBase.py b/libs/apprise/plugins/NotifyBase.py
index c0183c9f5..7e963b0ce 100644
--- a/libs/apprise/plugins/NotifyBase.py
+++ b/libs/apprise/plugins/NotifyBase.py
@@ -33,6 +33,7 @@ from ..common import NOTIFY_FORMATS
from ..common import OverflowMode
from ..common import OVERFLOW_MODES
from ..AppriseLocale import gettext_lazy as _
+from ..AppriseAttachment import AppriseAttachment
class NotifyBase(URLBase):
@@ -241,12 +242,21 @@ class NotifyBase(URLBase):
)
def notify(self, body, title=None, notify_type=NotifyType.INFO,
- overflow=None, **kwargs):
+ overflow=None, attach=None, **kwargs):
"""
Performs notification
"""
+ # Prepare attachments if required
+ if attach is not None and not isinstance(attach, AppriseAttachment):
+ try:
+ attach = AppriseAttachment(attach, asset=self.asset)
+
+ except TypeError:
+ # bad attachments
+ return False
+
# Handle situations where the title is None
title = '' if not title else title
@@ -255,7 +265,7 @@ class NotifyBase(URLBase):
overflow=overflow):
# Send notification
if not self.send(body=chunk['body'], title=chunk['title'],
- notify_type=notify_type):
+ notify_type=notify_type, attach=attach):
# Toggle our return status flag
return False
diff --git a/libs/apprise/plugins/NotifyBoxcar.py b/libs/apprise/plugins/NotifyBoxcar.py
index 5c74f44d6..341c5098c 100644
--- a/libs/apprise/plugins/NotifyBoxcar.py
+++ b/libs/apprise/plugins/NotifyBoxcar.py
@@ -38,7 +38,9 @@ except ImportError:
from urllib.parse import urlparse
from .NotifyBase import NotifyBase
+from ..URLBase import PrivacyMode
from ..utils import parse_bool
+from ..utils import validate_regex
from ..common import NotifyType
from ..common import NotifyImageSize
from ..AppriseLocale import gettext_lazy as _
@@ -57,11 +59,6 @@ IS_TAG = re.compile(r'^[@](?P<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