Upgrade Apprise to fix issue with Discord notification.

pull/684/head
Louis Vézina 5 years ago
parent 1967582020
commit 777913bd40

@ -30,14 +30,15 @@ from markdown import markdown
from itertools import chain
from .common import NotifyType
from .common import NotifyFormat
from .common import MATCH_ALL_TAG
from .utils import is_exclusive_match
from .utils import parse_list
from .utils import split_urls
from .utils import GET_SCHEMA_RE
from .logger import logger
from .AppriseAsset import AppriseAsset
from .AppriseConfig import AppriseConfig
from .AppriseAttachment import AppriseAttachment
from .AppriseLocale import AppriseLocale
from .config.ConfigBase import ConfigBase
from .plugins.NotifyBase import NotifyBase
@ -107,38 +108,8 @@ class Apprise(object):
results = None
if isinstance(url, six.string_types):
# swap hash (#) tag values with their html version
_url = url.replace('/#', '/%23')
# Attempt to acquire the schema at the very least to allow our
# plugins to determine if they can make a better interpretation of
# a URL geared for them
schema = GET_SCHEMA_RE.match(_url)
if schema is None:
logger.error(
'Unparseable schema:// found in URL {}.'.format(url))
return None
# Ensure our schema is always in lower case
schema = schema.group('schema').lower()
# Some basic validation
if schema not in plugins.SCHEMA_MAP:
# Give the user the benefit of the doubt that the user may be
# using one of the URLs provided to them by their notification
# service. Before we fail for good, just scan all the plugins
# that support he native_url() parse function
results = \
next((r['plugin'].parse_native_url(_url)
for r in plugins.MODULE_MAP.values()
if r['plugin'].parse_native_url(_url) is not None),
None)
else:
# Parse our url details of the server object as dictionary
# containing all of the information parsed from our URL
results = plugins.SCHEMA_MAP[schema].parse_url(_url)
# Acquire our url tokens
results = plugins.url_to_dict(url)
if results is None:
# Failed to parse the server URL
logger.error('Unparseable URL {}.'.format(url))
@ -273,30 +244,12 @@ class Apprise(object):
"""
self.servers[:] = []
def notify(self, body, title='', notify_type=NotifyType.INFO,
body_format=None, tag=None):
def find(self, tag=MATCH_ALL_TAG):
"""
Send a notification to all of the plugins previously loaded.
If the body_format specified is NotifyFormat.MARKDOWN, it will
be converted to HTML if the Notification type expects this.
if the tag is specified (either a string or a set/list/tuple
of strings), then only the notifications flagged with that
tagged value are notified. By default all added services
are notified (tag=None)
Returns an list of all servers matching against the tag specified.
"""
# Initialize our return result
status = len(self) > 0
if not (title or body):
return False
# Tracks conversions
conversion_map = dict()
# Build our tag setup
# - top level entries are treated as an 'or'
# - second level (or more) entries are treated as 'and'
@ -319,79 +272,135 @@ class Apprise(object):
for server in servers:
# Apply our tag matching based on our defined logic
if tag is not None and not is_exclusive_match(
logic=tag, data=server.tags):
continue
# If our code reaches here, we either did not define a tag (it
# was set to None), or we did define a tag and the logic above
# determined we need to notify the service it's associated with
if server.notify_format not in conversion_map:
if body_format == NotifyFormat.MARKDOWN and \
server.notify_format == NotifyFormat.HTML:
# Apply Markdown
conversion_map[server.notify_format] = markdown(body)
elif body_format == NotifyFormat.TEXT and \
server.notify_format == NotifyFormat.HTML:
# Basic TEXT to HTML format map; supports keys only
re_map = {
# Support Ampersand
r'&': '&',
# Spaces to   for formatting purposes since
# multiple spaces are treated as one an this may
# not be the callers intention
r' ': ' ',
# Tab support
r'\t': '   ',
# Greater than and Less than Characters
r'>': '>',
r'<': '&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

@ -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

@ -50,6 +50,21 @@ from .utils import parse_list
# Used to break a path list into parts
PATHSPLIT_LIST_DELIM = re.compile(r'[ \t\r\n,\\/]+')
class PrivacyMode(object):
# Defines different privacy modes strings can be printed as
# Astrisk sets 4 of them: e.g. ****
# This is used for passwords
Secret = '*'
# Outer takes the first and last character displaying them with
# 3 dots between. Hence, 'i-am-a-token' would become 'i...n'
Outer = 'o'
# Displays the last four characters
Tail = 't'
# Define the HTML Lookup Table
HTML_LOOKUP = {
400: 'Bad Request - Unsupported Parameters.',
@ -183,7 +198,7 @@ class URLBase(object):
self._last_io_datetime = datetime.now()
return
def url(self):
def url(self, privacy=False, *args, **kwargs):
"""
Assembles the URL associated with the notification based on the
arguments provied.
@ -204,6 +219,12 @@ class URLBase(object):
# return any match
return tags in self.tags
def __str__(self):
"""
Returns the url path
"""
return self.url(privacy=True)
@staticmethod
def escape_html(html, convert_new_lines=False, whitespace=True):
"""
@ -302,6 +323,44 @@ class URLBase(object):
# Python v2.7
return _quote(content, safe=safe)
@staticmethod
def pprint(content, privacy=True, mode=PrivacyMode.Outer,
# privacy print; quoting is ignored when privacy is set to True
quote=True, safe='/', encoding=None, errors=None):
"""
Privacy Print is used to mainpulate the string before passing it into
part of the URL. It is used to mask/hide private details such as
tokens, passwords, apikeys, etc from on-lookers. If the privacy=False
is set, then the quote variable is the next flag checked.
Quoting is never done if the privacy flag is set to true to avoid
skewing the expected output.
"""
if not privacy:
if quote:
# Return quoted string if specified to do so
return URLBase.quote(
content, safe=safe, encoding=encoding, errors=errors)
# Return content 'as-is'
return content
if mode is PrivacyMode.Secret:
# Return 4 Asterisks
return '****'
if not isinstance(content, six.string_types) or not content:
# Nothing more to do
return ''
if mode is PrivacyMode.Tail:
# Return the trailing 4 characters
return '...{}'.format(content[-4:])
# Default mode is Outer Mode
return '{}...{}'.format(content[0:1], content[-1:])
@staticmethod
def urlencode(query, doseq=False, safe='', encoding=None, errors=None):
"""Convert a mapping object or a sequence of two-element tuples

@ -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 <lead2gold@gmail.com>'
@ -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',
]

@ -99,6 +99,9 @@ def print_version_msg():
@click.option('--config', '-c', default=None, type=str, multiple=True,
metavar='CONFIG_URL',
help='Specify one or more configuration locations.')
@click.option('--attach', '-a', default=None, type=str, multiple=True,
metavar='ATTACHMENT_URL',
help='Specify one or more configuration locations.')
@click.option('--notification-type', '-n', default=NotifyType.INFO, type=str,
metavar='TYPE',
help='Specify the message type (default=info). Possible values'
@ -111,13 +114,17 @@ def print_version_msg():
'which services to notify. Use multiple --tag (-g) entries to '
'"OR" the tags together and comma separated to "AND" them. '
'If no tags are specified then all services are notified.')
@click.option('-v', '--verbose', count=True)
@click.option('-V', '--version', is_flag=True,
@click.option('--dry-run', '-d', is_flag=True,
help='Perform a trial run but only prints the notification '
'services to-be triggered to stdout. Notifications are never '
'sent using this mode.')
@click.option('--verbose', '-v', count=True)
@click.option('--version', '-V', is_flag=True,
help='Display the apprise version and exit.')
@click.argument('urls', nargs=-1,
metavar='SERVER_URL [SERVER_URL2 [SERVER_URL3]]',)
def main(body, title, config, urls, notification_type, theme, tag, verbose,
version):
def main(body, title, config, attach, urls, notification_type, theme, tag,
dry_run, verbose, version):
"""
Send a notification to all of the specified servers identified by their
URLs the content provided within the title, body and notification-type.
@ -184,16 +191,52 @@ def main(body, title, config, urls, notification_type, theme, tag, verbose,
print_help_msg(main)
sys.exit(1)
if body is None:
# if no body was specified, then read from STDIN
body = click.get_text_stream('stdin').read()
# each --tag entry comprises of a comma separated 'and' list
# we or each of of the --tag and sets specified.
tags = None if not tag else [parse_list(t) for t in tag]
# now print it out
if a.notify(
body=body, title=title, notify_type=notification_type, tag=tags):
sys.exit(0)
sys.exit(1)
if not dry_run:
if body is None:
logger.trace('No --body (-b) specified; reading from stdin')
# if no body was specified, then read from STDIN
body = click.get_text_stream('stdin').read()
# now print it out
result = a.notify(
body=body, title=title, notify_type=notification_type, tag=tags,
attach=attach)
else:
# Number of rows to assume in the terminal. In future, maybe this can
# be detected and made dynamic. The actual row count is 80, but 5
# characters are already reserved for the counter on the left
rows = 75
# Initialize our URL response; This is populated within the for/loop
# below; but plays a factor at the end when we need to determine if
# we iterated at least once in the loop.
url = None
for idx, server in enumerate(a.find(tag=tags)):
url = server.url(privacy=True)
click.echo("{: 3d}. {}".format(
idx + 1,
url if len(url) <= rows else '{}...'.format(url[:rows - 3])))
if server.tags:
click.echo("{} - {}".format(' ' * 5, ', '.join(server.tags)))
# Initialize a default response of nothing matched, otherwise
# if we matched at least one entry, we can return True
result = None if url is None else True
if result is None:
# There were no notifications set. This is a result of just having
# empty configuration files and/or being to restrictive when filtering
# by specific tag(s)
sys.exit(2)
elif result is False:
# At least 1 notification service failed to send
sys.exit(1)
# else: We're good!
sys.exit(0)

@ -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'

@ -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

@ -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

@ -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

@ -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)

@ -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

@ -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]),

@ -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))

@ -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),
)

@ -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

@ -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

@ -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(

@ -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),
)

@ -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(

@ -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))

@ -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.
"""

@ -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),
)

@ -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
@ -88,26 +89,32 @@ class NotifyGrowl(NotifyBase):
# Default Growl Port
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.

@ -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(

@ -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),
)

@ -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']:

@ -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(

@ -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))

@ -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:

@ -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

@ -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]),

@ -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),
)

@ -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))

@ -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'])

@ -1,175 +0,0 @@
# -*- coding: utf-8 -*-
#
# Copyright (C) 2019 Chris Caron <lead2gold@gmail.com>
# 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

@ -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

@ -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>`__.
"""

@ -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

@ -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

@ -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'] = \

@ -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

@ -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(

@ -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(

@ -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(

@ -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),
)

@ -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))

@ -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]),

@ -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(

@ -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]),

@ -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(

@ -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.
"""

@ -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,

@ -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 _
@ -57,7 +58,6 @@ class NotifyXML(NotifyBase):
# local anyway
request_rate_per_sec = 0
# Define object templates
# Define object templates
templates = (
'{schema}://{host}',
@ -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),
)

@ -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,

@ -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),

@ -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

@ -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):
"""

@ -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

Loading…
Cancel
Save