mirror of
https://github.com/morpheus65535/bazarr.git
synced 2025-04-23 22:27:17 -04:00
Upgrade Apprise to fix issue with Discord notification.
This commit is contained in:
parent
1967582020
commit
777913bd40
59 changed files with 2519 additions and 1945 deletions
|
@ -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,78 +272,134 @@ 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 is_exclusive_match(
|
||||
logic=tag, data=server.tags, match_all=MATCH_ALL_TAG):
|
||||
yield server
|
||||
return
|
||||
|
||||
# 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:
|
||||
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.
|
||||
|
||||
# Apply Markdown
|
||||
conversion_map[server.notify_format] = markdown(body)
|
||||
If the body_format specified is NotifyFormat.MARKDOWN, it will
|
||||
be converted to HTML if the Notification type expects this.
|
||||
|
||||
elif body_format == NotifyFormat.TEXT and \
|
||||
server.notify_format == NotifyFormat.HTML:
|
||||
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)
|
||||
|
||||
# Basic TEXT to HTML format map; supports keys only
|
||||
re_map = {
|
||||
# Support Ampersand
|
||||
r'&': '&',
|
||||
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.
|
||||
|
||||
# Spaces to for formatting purposes since
|
||||
# multiple spaces are treated as one an this may
|
||||
# not be the callers intention
|
||||
r' ': ' ',
|
||||
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
|
||||
"""
|
||||
|
||||
# Tab support
|
||||
r'\t': ' ',
|
||||
if len(self) == 0:
|
||||
# Nothing to notify
|
||||
return False
|
||||
|
||||
# Greater than and Less than Characters
|
||||
r'>': '>',
|
||||
r'<': '<',
|
||||
}
|
||||
# Initialize our return result which only turns to True if we send
|
||||
# at least one valid notification
|
||||
status = None
|
||||
|
||||
# Compile our map
|
||||
re_table = re.compile(
|
||||
r'(' + '|'.join(
|
||||
map(re.escape, re_map.keys())) + r')',
|
||||
re.IGNORECASE,
|
||||
)
|
||||
if not (title or body):
|
||||
return False
|
||||
|
||||
# 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))
|
||||
# Tracks conversions
|
||||
conversion_map = dict()
|
||||
|
||||
else:
|
||||
# Store entry directly
|
||||
conversion_map[server.notify_format] = body
|
||||
# Prepare attachments if required
|
||||
if attach is not None and not isinstance(attach, AppriseAttachment):
|
||||
try:
|
||||
attach = AppriseAttachment(attach, asset=self.asset)
|
||||
|
||||
try:
|
||||
# Send notification
|
||||
if not server.notify(
|
||||
body=conversion_map[server.notify_format],
|
||||
title=title,
|
||||
notify_type=notify_type):
|
||||
except TypeError:
|
||||
# bad attachments
|
||||
return False
|
||||
|
||||
# Toggle our return status flag
|
||||
status = 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
|
||||
|
||||
except TypeError:
|
||||
# These our our internally thrown notifications
|
||||
# If our code reaches here, we either did not define a tag (it
|
||||
# was set to None), or we did define a tag and the logic above
|
||||
# determined we need to notify the service it's associated with
|
||||
if server.notify_format not in conversion_map:
|
||||
if body_format == NotifyFormat.MARKDOWN and \
|
||||
server.notify_format == NotifyFormat.HTML:
|
||||
|
||||
# Apply Markdown
|
||||
conversion_map[server.notify_format] = markdown(body)
|
||||
|
||||
elif body_format == NotifyFormat.TEXT and \
|
||||
server.notify_format == NotifyFormat.HTML:
|
||||
|
||||
# Basic TEXT to HTML format map; supports keys only
|
||||
re_map = {
|
||||
# Support Ampersand
|
||||
r'&': '&',
|
||||
|
||||
# Spaces to for formatting purposes since
|
||||
# multiple spaces are treated as one an this may
|
||||
# not be the callers intention
|
||||
r' ': ' ',
|
||||
|
||||
# Tab support
|
||||
r'\t': ' ',
|
||||
|
||||
# Greater than and Less than Characters
|
||||
r'>': '>',
|
||||
r'<': '<',
|
||||
}
|
||||
|
||||
# Compile our map
|
||||
re_table = re.compile(
|
||||
r'(' + '|'.join(
|
||||
map(re.escape, re_map.keys())) + r')',
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
# Execute our map against our body in addition to
|
||||
# swapping out new lines and replacing them with <br/>
|
||||
conversion_map[server.notify_format] = \
|
||||
re.sub(r'\r*\n', '<br/>\r\n',
|
||||
re_table.sub(
|
||||
lambda x: re_map[x.group()], body))
|
||||
|
||||
else:
|
||||
# Store entry directly
|
||||
conversion_map[server.notify_format] = body
|
||||
|
||||
try:
|
||||
# Send notification
|
||||
if not server.notify(
|
||||
body=conversion_map[server.notify_format],
|
||||
title=title,
|
||||
notify_type=notify_type,
|
||||
attach=attach):
|
||||
|
||||
# Toggle our return status flag
|
||||
status = False
|
||||
|
||||
except 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
|
||||
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
|
||||
|
||||
|
@ -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'):
|
||||
# Store our url read in
|
||||
url = result.group('url')
|
||||
if not url:
|
||||
# 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))
|
||||
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))
|
||||
'Invalid URL {}, entry #{}'.format(url, 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))
|
||||
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()))
|
||||
it = iter(url.items())
|
||||
|
||||
# swap hash (#) tag values with their html version
|
||||
_url = _url.replace('/#', '/%23')
|
||||
# Track the URL to-load
|
||||
_url = None
|
||||
|
||||
# Get our schema
|
||||
schema = GET_SCHEMA_RE.match(_url)
|
||||
if schema is 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(no + 1))
|
||||
'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))
|
||||
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,
|
||||
)
|
||||
with requests.post(
|
||||
url,
|
||||
headers=headers,
|
||||
auth=auth,
|
||||
verify=self.verify_certificate,
|
||||
timeout=self.connection_timeout_sec,
|
||||
stream=True) as r:
|
||||
|
||||
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))
|
||||
# Handle Errors
|
||||
r.raise_for_status()
|
||||
|
||||
# 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]))
|
||||
# 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
|
||||
|
||||
# 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()
|
||||
# Store our response
|
||||
if self.max_buffer_size > 0 \
|
||||
and file_size > self.max_buffer_size:
|
||||
|
||||
# Return None (signifying a failure)
|
||||
return None
|
||||
# Provide warning of data truncation
|
||||
self.logger.error(
|
||||
'HTTP config response exceeds maximum buffer length '
|
||||
'({}KB);'.format(int(self.max_buffer_size / 1024)))
|
||||
|
||||
# Store our response
|
||||
if self.max_buffer_size > 0 and \
|
||||
r.headers['Content-Length'] > self.max_buffer_size:
|
||||
# Return None - buffer execeeded
|
||||
return None
|
||||
|
||||
# Provide warning of data truncation
|
||||
self.logger.error(
|
||||
'HTTP config response exceeds maximum buffer length '
|
||||
'({}KB);'.format(int(self.max_buffer_size / 1024)))
|
||||
# Store our result (but no more than our buffer length)
|
||||
response = r.content[:self.max_buffer_size + 1]
|
||||
|
||||
# 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()
|
||||
# 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
|
||||
|
||||
else:
|
||||
# Store our result
|
||||
response = r.content
|
||||
# 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)
|
||||
|
||||
|
|
Binary file not shown.
|
@ -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.'
|
||||
# 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)
|
||||
|
||||
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)
|
||||
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')
|
||||
content = MIMEText(body, 'plain')
|
||||
|
||||
email['Subject'] = title
|
||||
email['From'] = '{} <{}>'.format(from_name, self.from_addr)
|
||||
email['To'] = to_addr
|
||||
email['Date'] = \
|
||||
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 = '\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,37 +152,79 @@ 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)
|
||||
self.logger.warning(msg)
|
||||
raise TypeError(msg)
|
||||
|
||||
# The token associated with the account
|
||||
self.apikey = apikey.strip()
|
||||
|
||||
# Parse devices specified
|
||||
self.devices = parse_list(targets)
|
||||
|
||||
if len(self.devices) == 0:
|
||||
# Default to everyone
|
||||
self.devices.append(self.default_join_group)
|
||||
|
||||
# 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 Priority of the message
|
||||
if priority not in JOIN_PRIORITIES:
|
||||
self.priority = self.template_args['priority']['default']
|
||||
|
||||
else:
|
||||
self.priority = priority
|
||||
|
||||
# Prepare a list of targets to store entries into
|
||||
self.targets = list()
|
||||
|
||||
# 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):
|
||||
"""
|
||||
Perform Join Notification
|
||||
|
@ -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.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)
|
||||
|
||||
if not token_b:
|
||||
msg = 'The second MSTeams API token is not specified.'
|
||||
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)
|
||||
|
||||
if not token_c:
|
||||
msg = 'The third MSTeams API token is not specified.'
|
||||
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)
|
||||
|
||||
if not VALIDATE_TOKEN_A.match(token_a.strip()):
|
||||
msg = 'The first MSTeams 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 MSTeams 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 MSTeams 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()
|
||||
|
||||
# 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))
|
||||
|
||||
# Always call throttle before any remote server i/o is made
|
||||
self.throttle()
|
||||
|
||||
try:
|
||||
r = requests.post(
|
||||
self.notify_url,
|
||||
data=dumps(payload),
|
||||
headers=headers,
|
||||
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
|
||||
has_error = True
|
||||
continue
|
||||
|
||||
else:
|
||||
self.logger.info(
|
||||
'Sent PushBullet notification to "%s".' % (recipient))
|
||||
|
||||
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))
|
||||
|
||||
# Mark our failure
|
||||
okay, response = self._send(
|
||||
self.notify_url.format('pushes'), payload)
|
||||
if not okay:
|
||||
has_error = True
|
||||
continue
|
||||
|
||||
self.logger.info(
|
||||
'Sent PushBullet notification to "%s".' % (recipient))
|
||||
|
||||
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
|
||||
|
||||
self.logger.info(
|
||||
'Sent PushBullet attachment (%s) to "%s".' % (
|
||||
attach_payload['file_name'], recipient))
|
||||
|
||||
return not has_error
|
||||
|
||||
def url(self):
|
||||
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)
|
||||
|
||||
self.logger.warning(
|
||||
'Failed to deliver payload to PushBullet:'
|
||||
'{}{}error={}.'.format(
|
||||
status_str,
|
||||
', ' if status_str else '',
|
||||
r.status_code))
|
||||
|
||||
self.logger.debug(
|
||||
'Response Details:\r\n{}'.format(r.content))
|
||||
|
||||
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, 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.'
|
||||
# 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 organization:
|
||||
msg = 'No Ryver organization was specified.'
|
||||
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)
|
||||
# Setup our mode
|
||||
self.mode = SlackMode.BOT if access_token else SlackMode.WEBHOOK
|
||||
|
||||
if not token_b:
|
||||
msg = 'The second API token is not specified.'
|
||||
self.logger.warning(msg)
|
||||
raise TypeError(msg)
|
||||
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)
|
||||
|
||||
if not token_c:
|
||||
msg = 'The third API token is not specified.'
|
||||
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)
|
||||
|
||||
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()
|
||||
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)
|
||||
|
||||
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.debug(
|
||||
'Response Details:\r\n{}'.format(r.content))
|
||||
|
||||
# Mark our failure
|
||||
has_error = True
|
||||
continue
|
||||
|
||||
else:
|
||||
self.logger.info(
|
||||
'Sent Slack notification{}.'.format(
|
||||
' to {}'.format(channel)
|
||||
if channel is not None else ''))
|
||||
|
||||
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))
|
||||
|
||||
# Mark our failure
|
||||
response = self._send(url, payload)
|
||||
if not response:
|
||||
# Handle any error
|
||||
has_error = True
|
||||
continue
|
||||
|
||||
self.logger.info(
|
||||
'Sent Slack notification{}.'.format(
|
||||
' to {}'.format(channel)
|
||||
if channel is not None else ''))
|
||||
|
||||
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))
|
||||
|
||||
# Prepare API Upload Payload
|
||||
_payload = {
|
||||
'filename': attachment.name,
|
||||
'channels': ','.join(attach_channel_list)
|
||||
}
|
||||
|
||||
# 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 url(self):
|
||||
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)
|
||||
|
||||
self.logger.warning(
|
||||
'Failed to send {}to Slack: '
|
||||
'{}{}error={}.'.format(
|
||||
attach.name if attach else '',
|
||||
status_str,
|
||||
', ' if status_str else '',
|
||||
r.status_code))
|
||||
|
||||
self.logger.debug(
|
||||
'Response Details:\r\n{}'.format(r.content))
|
||||
return False
|
||||
|
||||
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
|
||||
|
||||
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,19 +106,14 @@ 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)
|
||||
self.logger.warning(msg)
|
||||
raise TypeError(msg)
|
||||
|
||||
# The apikey associated with the account
|
||||
self.apikey = apikey.strip()
|
||||
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)
|
||||
|
||||
def send(self, body, title='', notify_type=NotifyType.INFO, **kwargs):
|
||||
"""
|
||||
|
@ -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.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)
|
||||
|
||||
result = VALIDATE_BOT_TOKEN.match(self.bot_token)
|
||||
if not result:
|
||||
err = 'The Bot Token specified (%s) is invalid.' % 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,
|
||||
)
|
||||
r = requests.post(
|
||||
url,
|
||||
headers=headers,
|
||||
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)
|
||||
if r.status_code != requests.codes.ok:
|
||||
# We had a problem
|
||||
status_str = NotifyTelegram\
|
||||
.http_response_code_lookup(r.status_code)
|
||||
|
||||
self.logger.warning(
|
||||
'Failed to send Telegram image: '
|
||||
'{}{}error={}.'.format(
|
||||
status_str,
|
||||
', ' if status_str else '',
|
||||
r.status_code))
|
||||
|
||||
self.logger.debug(
|
||||
'Response Details:\r\n{}'.format(r.content))
|
||||
|
||||
return False
|
||||
|
||||
except requests.RequestException as e:
|
||||
self.logger.warning(
|
||||
'A connection error occured posting Telegram image.')
|
||||
self.logger.debug('Socket Exception: %s' % str(e))
|
||||
'Failed to send Telegram attachment: '
|
||||
'{}{}error={}.'.format(
|
||||
status_str,
|
||||
', ' if status_str else '',
|
||||
r.status_code))
|
||||
|
||||
self.logger.debug(
|
||||
'Response Details:\r\n{}'.format(r.content))
|
||||
|
||||
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.'
|
||||
# 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)
|
||||
|
||||
if not VALIDATE_ACCOUNT_SID.match(self.account_sid):
|
||||
msg = 'The Account SID specified ({}) is invalid.' \
|
||||
.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…
Add table
Add a link
Reference in a new issue