Upgraded Apprise to 1.1.0 version.

This commit is contained in:
morpheus65535 2022-10-10 21:19:24 -04:00
parent 61ef236d04
commit 04b095995c
95 changed files with 4667 additions and 2111 deletions

View file

@ -24,18 +24,14 @@
# THE SOFTWARE. # THE SOFTWARE.
import os import os
import six
from itertools import chain from itertools import chain
from .common import NotifyType from . import common
from .common import MATCH_ALL_TAG
from .common import MATCH_ALWAYS_TAG
from .conversion import convert_between from .conversion import convert_between
from .utils import is_exclusive_match from .utils import is_exclusive_match
from .utils import parse_list from .utils import parse_list
from .utils import parse_urls from .utils import parse_urls
from .utils import cwe312_url from .utils import cwe312_url
from .logger import logger from .logger import logger
from .AppriseAsset import AppriseAsset from .AppriseAsset import AppriseAsset
from .AppriseConfig import AppriseConfig from .AppriseConfig import AppriseConfig
from .AppriseAttachment import AppriseAttachment from .AppriseAttachment import AppriseAttachment
@ -47,13 +43,13 @@ from .plugins.NotifyBase import NotifyBase
from . import plugins from . import plugins
from . import __version__ from . import __version__
# Python v3+ support code made importable so it can remain backwards # Python v3+ support code made importable, so it can remain backwards
# compatible with Python v2 # compatible with Python v2
# TODO: Review after dropping support for Python 2.
from . import py3compat from . import py3compat
ASYNCIO_SUPPORT = not six.PY2
class Apprise(object): class Apprise:
""" """
Our Notification Manager Our Notification Manager
@ -127,7 +123,7 @@ class Apprise(object):
# Prepare our Asset Object # Prepare our Asset Object
asset = asset if isinstance(asset, AppriseAsset) else AppriseAsset() asset = asset if isinstance(asset, AppriseAsset) else AppriseAsset()
if isinstance(url, six.string_types): if isinstance(url, str):
# Acquire our url tokens # Acquire our url tokens
results = plugins.url_to_dict( results = plugins.url_to_dict(
url, secure_logging=asset.secure_logging) url, secure_logging=asset.secure_logging)
@ -141,7 +137,7 @@ class Apprise(object):
# We already have our result set # We already have our result set
results = url results = url
if results.get('schema') not in plugins.SCHEMA_MAP: if results.get('schema') not in common.NOTIFY_SCHEMA_MAP:
# schema is a mandatory dictionary item as it is the only way # schema is a mandatory dictionary item as it is the only way
# we can index into our loaded plugins # we can index into our loaded plugins
logger.error('Dictionary does not include a "schema" entry.') logger.error('Dictionary does not include a "schema" entry.')
@ -164,7 +160,7 @@ class Apprise(object):
type(url)) type(url))
return None return None
if not plugins.SCHEMA_MAP[results['schema']].enabled: if not common.NOTIFY_SCHEMA_MAP[results['schema']].enabled:
# #
# First Plugin Enable Check (Pre Initialization) # First Plugin Enable Check (Pre Initialization)
# #
@ -184,12 +180,13 @@ class Apprise(object):
try: try:
# Attempt to create an instance of our plugin using the parsed # Attempt to create an instance of our plugin using the parsed
# URL information # URL information
plugin = plugins.SCHEMA_MAP[results['schema']](**results) plugin = common.NOTIFY_SCHEMA_MAP[results['schema']](**results)
# Create log entry of loaded URL # Create log entry of loaded URL
logger.debug( logger.debug(
'Loaded {} URL: {}'.format( 'Loaded {} URL: {}'.format(
plugins.SCHEMA_MAP[results['schema']].service_name, common.
NOTIFY_SCHEMA_MAP[results['schema']].service_name,
plugin.url(privacy=asset.secure_logging))) plugin.url(privacy=asset.secure_logging)))
except Exception: except Exception:
@ -200,14 +197,15 @@ class Apprise(object):
# the arguments are invalid or can not be used. # the arguments are invalid or can not be used.
logger.error( logger.error(
'Could not load {} URL: {}'.format( 'Could not load {} URL: {}'.format(
plugins.SCHEMA_MAP[results['schema']].service_name, common.
NOTIFY_SCHEMA_MAP[results['schema']].service_name,
loggable_url)) loggable_url))
return None return None
else: else:
# Attempt to create an instance of our plugin using the parsed # Attempt to create an instance of our plugin using the parsed
# URL information but don't wrap it in a try catch # URL information but don't wrap it in a try catch
plugin = plugins.SCHEMA_MAP[results['schema']](**results) plugin = common.NOTIFY_SCHEMA_MAP[results['schema']](**results)
if not plugin.enabled: if not plugin.enabled:
# #
@ -248,7 +246,7 @@ class Apprise(object):
# prepare default asset # prepare default asset
asset = self.asset asset = self.asset
if isinstance(servers, six.string_types): if isinstance(servers, str):
# build our server list # build our server list
servers = parse_urls(servers) servers = parse_urls(servers)
if len(servers) == 0: if len(servers) == 0:
@ -276,7 +274,7 @@ class Apprise(object):
self.servers.append(_server) self.servers.append(_server)
continue continue
elif not isinstance(_server, (six.string_types, dict)): elif not isinstance(_server, (str, dict)):
logger.error( logger.error(
"An invalid notification (type={}) was specified.".format( "An invalid notification (type={}) was specified.".format(
type(_server))) type(_server)))
@ -305,9 +303,9 @@ class Apprise(object):
""" """
self.servers[:] = [] self.servers[:] = []
def find(self, tag=MATCH_ALL_TAG, match_always=True): def find(self, tag=common.MATCH_ALL_TAG, match_always=True):
""" """
Returns an list of all servers matching against the tag specified. Returns a list of all servers matching against the tag specified.
""" """
@ -323,7 +321,7 @@ class Apprise(object):
# A match_always flag allows us to pick up on our 'any' keyword # A match_always flag allows us to pick up on our 'any' keyword
# and notify these services under all circumstances # and notify these services under all circumstances
match_always = MATCH_ALWAYS_TAG if match_always else None match_always = common.MATCH_ALWAYS_TAG if match_always else None
# Iterate over our loaded plugins # Iterate over our loaded plugins
for entry in self.servers: for entry in self.servers:
@ -338,23 +336,24 @@ class Apprise(object):
for server in servers: for server in servers:
# Apply our tag matching based on our defined logic # Apply our tag matching based on our defined logic
if is_exclusive_match( if is_exclusive_match(
logic=tag, data=server.tags, match_all=MATCH_ALL_TAG, logic=tag, data=server.tags,
match_all=common.MATCH_ALL_TAG,
match_always=match_always): match_always=match_always):
yield server yield server
return return
def notify(self, body, title='', notify_type=NotifyType.INFO, def notify(self, body, title='', notify_type=common.NotifyType.INFO,
body_format=None, tag=MATCH_ALL_TAG, match_always=True, body_format=None, tag=common.MATCH_ALL_TAG, match_always=True,
attach=None, interpret_escapes=None): attach=None, interpret_escapes=None):
""" """
Send a notification to all of the plugins previously loaded. Send a notification to all the plugins previously loaded.
If the body_format specified is NotifyFormat.MARKDOWN, it will If the body_format specified is NotifyFormat.MARKDOWN, it will
be converted to HTML if the Notification type expects this. be converted to HTML if the Notification type expects this.
if the tag is specified (either a string or a set/list/tuple if the tag is specified (either a string or a set/list/tuple
of strings), then only the notifications flagged with that of strings), then only the notifications flagged with that
tagged value are notified. By default all added services tagged value are notified. By default, all added services
are notified (tag=MATCH_ALL_TAG) are notified (tag=MATCH_ALL_TAG)
This function returns True if all notifications were successfully This function returns True if all notifications were successfully
@ -363,60 +362,33 @@ class Apprise(object):
simply having empty configuration files that were read. simply having empty configuration files that were read.
Attach can contain a list of attachment URLs. attach can also be Attach can contain a list of attachment URLs. attach can also be
represented by a an AttachBase() (or list of) object(s). This represented by an AttachBase() (or list of) object(s). This
identifies the products you wish to notify identifies the products you wish to notify
Set interpret_escapes to True if you want to pre-escape a string Set interpret_escapes to True if you want to pre-escape a string
such as turning a \n into an actual new line, etc. such as turning a \n into an actual new line, etc.
""" """
if ASYNCIO_SUPPORT: return py3compat.asyncio.tosync(
return py3compat.asyncio.tosync( self.async_notify(
self.async_notify( body, title,
body, title, notify_type=notify_type, body_format=body_format,
notify_type=notify_type, body_format=body_format, tag=tag, match_always=match_always, attach=attach,
tag=tag, match_always=match_always, attach=attach, interpret_escapes=interpret_escapes,
interpret_escapes=interpret_escapes, ),
), debug=self.debug
debug=self.debug )
)
else:
try:
results = list(
self._notifyall(
Apprise._notifyhandler,
body, title,
notify_type=notify_type, body_format=body_format,
tag=tag, attach=attach,
interpret_escapes=interpret_escapes,
)
)
except TypeError:
# No notifications sent, and there was an internal error.
return False
else:
if len(results) > 0:
# All notifications sent, return False if any failed.
return all(results)
else:
# No notifications sent.
return None
def async_notify(self, *args, **kwargs): def async_notify(self, *args, **kwargs):
""" """
Send a notification to all of the plugins previously loaded, for Send a notification to all the plugins previously loaded, for
asynchronous callers. This method is an async method that should be asynchronous callers. This method is an async method that should be
awaited on, even if it is missing the async keyword in its signature. awaited on, even if it is missing the async keyword in its signature.
(This is omitted to preserve syntax compatibility with Python 2.) (This is omitted to preserve syntax compatibility with Python 2.)
The arguments are identical to those of Apprise.notify(). This method The arguments are identical to those of Apprise.notify().
is not available in Python 2.
"""
"""
try: try:
coroutines = list( coroutines = list(
self._notifyall( self._notifyall(
@ -424,7 +396,7 @@ class Apprise(object):
except TypeError: except TypeError:
# No notifications sent, and there was an internal error. # No notifications sent, and there was an internal error.
return py3compat.asyncio.toasyncwrap(False) return py3compat.asyncio.toasyncwrapvalue(False)
else: else:
if len(coroutines) > 0: if len(coroutines) > 0:
@ -433,7 +405,7 @@ class Apprise(object):
else: else:
# No notifications sent. # No notifications sent.
return py3compat.asyncio.toasyncwrap(None) return py3compat.asyncio.toasyncwrapvalue(None)
@staticmethod @staticmethod
def _notifyhandler(server, **kwargs): def _notifyhandler(server, **kwargs):
@ -470,13 +442,14 @@ class Apprise(object):
# Send the notification immediately, and wrap the result in a # Send the notification immediately, and wrap the result in a
# coroutine. # coroutine.
status = Apprise._notifyhandler(server, **kwargs) status = Apprise._notifyhandler(server, **kwargs)
return py3compat.asyncio.toasyncwrap(status) return py3compat.asyncio.toasyncwrapvalue(status)
def _notifyall(self, handler, body, title='', notify_type=NotifyType.INFO, def _notifyall(self, handler, body, title='',
body_format=None, tag=MATCH_ALL_TAG, match_always=True, notify_type=common.NotifyType.INFO, body_format=None,
attach=None, interpret_escapes=None): tag=common.MATCH_ALL_TAG, match_always=True, attach=None,
interpret_escapes=None):
""" """
Creates notifications for all of the plugins loaded. Creates notifications for all the plugins loaded.
Returns a generator that calls handler for each notification. The first Returns a generator that calls handler for each notification. The first
and only argument supplied to handler is the server, and the keyword and only argument supplied to handler is the server, and the keyword
@ -485,7 +458,7 @@ class Apprise(object):
if len(self) == 0: if len(self) == 0:
# Nothing to notify # Nothing to notify
msg = "There are service(s) to notify" msg = "There are no service(s) to notify"
logger.error(msg) logger.error(msg)
raise TypeError(msg) raise TypeError(msg)
@ -495,23 +468,11 @@ class Apprise(object):
raise TypeError(msg) raise TypeError(msg)
try: try:
if six.PY2: if title and isinstance(title, bytes):
# Python 2.7 encoding support isn't the greatest, so we try title = title.decode(self.asset.encoding)
# to ensure that we're ALWAYS dealing with unicode characters
# prior to entrying the next part. This is especially required
# for Markdown support
if title and isinstance(title, str): # noqa: F821
title = title.decode(self.asset.encoding)
if body and isinstance(body, str): # noqa: F821 if body and isinstance(body, bytes):
body = body.decode(self.asset.encoding) body = body.decode(self.asset.encoding)
else: # Python 3+
if title and isinstance(title, bytes): # noqa: F821
title = title.decode(self.asset.encoding)
if body and isinstance(body, bytes): # noqa: F821
body = body.decode(self.asset.encoding)
except UnicodeDecodeError: except UnicodeDecodeError:
msg = 'The content passed into Apprise was not of encoding ' \ msg = 'The content passed into Apprise was not of encoding ' \
@ -579,43 +540,12 @@ class Apprise(object):
.encode('ascii', 'backslashreplace')\ .encode('ascii', 'backslashreplace')\
.decode('unicode-escape') .decode('unicode-escape')
except UnicodeDecodeError: # pragma: no cover
# This occurs using a very old verion of Python 2.7
# such as the one that ships with CentOS/RedHat 7.x
# (v2.7.5).
conversion_body_map[server.notify_format] = \
conversion_body_map[server.notify_format] \
.decode('string_escape')
conversion_title_map[server.notify_format] = \
conversion_title_map[server.notify_format] \
.decode('string_escape')
except AttributeError: except AttributeError:
# Must be of string type # Must be of string type
msg = 'Failed to escape message body' msg = 'Failed to escape message body'
logger.error(msg) logger.error(msg)
raise TypeError(msg) raise TypeError(msg)
if six.PY2:
# Python 2.7 strings must be encoded as utf-8 for
# consistency across all platforms
if conversion_body_map[server.notify_format] and \
isinstance(
conversion_body_map[server.notify_format],
unicode): # noqa: F821
conversion_body_map[server.notify_format] = \
conversion_body_map[server.notify_format]\
.encode('utf-8')
if conversion_title_map[server.notify_format] and \
isinstance(
conversion_title_map[server.notify_format],
unicode): # noqa: F821
conversion_title_map[server.notify_format] = \
conversion_title_map[server.notify_format]\
.encode('utf-8')
yield handler( yield handler(
server, server,
body=conversion_body_map[server.notify_format], body=conversion_body_map[server.notify_format],
@ -641,7 +571,7 @@ class Apprise(object):
'asset': self.asset.details(), 'asset': self.asset.details(),
} }
for plugin in set(plugins.SCHEMA_MAP.values()): for plugin in set(common.NOTIFY_SCHEMA_MAP.values()):
# Iterate over our hashed plugins and dynamically build details on # Iterate over our hashed plugins and dynamically build details on
# their status: # their status:
@ -650,7 +580,10 @@ class Apprise(object):
'service_url': getattr(plugin, 'service_url', None), 'service_url': getattr(plugin, 'service_url', None),
'setup_url': getattr(plugin, 'setup_url', None), 'setup_url': getattr(plugin, 'setup_url', None),
# Placeholder - populated below # Placeholder - populated below
'details': None 'details': None,
# Differentiat between what is a custom loaded plugin and
# which is native.
'category': getattr(plugin, 'category', None)
} }
# Standard protocol(s) should be None or a tuple # Standard protocol(s) should be None or a tuple
@ -665,12 +598,12 @@ class Apprise(object):
# Standard protocol(s) should be None or a tuple # Standard protocol(s) should be None or a tuple
protocols = getattr(plugin, 'protocol', None) protocols = getattr(plugin, 'protocol', None)
if isinstance(protocols, six.string_types): if isinstance(protocols, str):
protocols = (protocols, ) protocols = (protocols, )
# Secure protocol(s) should be None or a tuple # Secure protocol(s) should be None or a tuple
secure_protocols = getattr(plugin, 'secure_protocol', None) secure_protocols = getattr(plugin, 'secure_protocol', None)
if isinstance(secure_protocols, six.string_types): if isinstance(secure_protocols, str):
secure_protocols = (secure_protocols, ) secure_protocols = (secure_protocols, )
# Add our protocol details to our content # Add our protocol details to our content
@ -775,15 +708,8 @@ class Apprise(object):
def __bool__(self): def __bool__(self):
""" """
Allows the Apprise object to be wrapped in an Python 3.x based 'if Allows the Apprise object to be wrapped in an 'if statement'.
statement'. True is returned if at least one service has been loaded. 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 return len(self) > 0
@ -803,7 +729,3 @@ class Apprise(object):
""" """
return sum([1 if not isinstance(s, (ConfigBase, AppriseConfig)) return sum([1 if not isinstance(s, (ConfigBase, AppriseConfig))
else len(s.servers()) for s in self.servers]) else len(s.servers()) for s in self.servers])
if six.PY2:
del Apprise.async_notify

View file

@ -58,6 +58,5 @@ class Apprise:
def pop(self, index: int) -> ConfigBase: ... def pop(self, index: int) -> ConfigBase: ...
def __getitem__(self, index: int) -> ConfigBase: ... def __getitem__(self, index: int) -> ConfigBase: ...
def __bool__(self) -> bool: ... def __bool__(self) -> bool: ...
def __nonzero__(self) -> bool: ...
def __iter__(self) -> Iterator[ConfigBase]: ... def __iter__(self) -> Iterator[ConfigBase]: ...
def __len__(self) -> int: ... def __len__(self) -> int: ...

View file

@ -30,15 +30,20 @@ from os.path import dirname
from os.path import isfile from os.path import isfile
from os.path import abspath from os.path import abspath
from .common import NotifyType from .common import NotifyType
from .utils import module_detection
class AppriseAsset(object): class AppriseAsset:
""" """
Provides a supplimentary class that can be used to provide extra Provides a supplimentary class that can be used to provide extra
information and details that can be used by Apprise such as providing information and details that can be used by Apprise such as providing
an alternate location to where images/icons can be found and the an alternate location to where images/icons can be found and the
URL masks. URL masks.
Any variable that starts with an underscore (_) can only be initialized
by this class manually and will/can not be parsed from a configuration
file.
""" """
# Application Identifier # Application Identifier
app_id = 'Apprise' app_id = 'Apprise'
@ -102,8 +107,8 @@ class AppriseAsset(object):
# - NotifyFormat.HTML # - NotifyFormat.HTML
# - None # - None
# #
# If no format is specified (hence None), then no special pre-formating # If no format is specified (hence None), then no special pre-formatting
# actions will take place during a notificaton. This has been and always # actions will take place during a notification. This has been and always
# will be the default. # will be the default.
body_format = None body_format = None
@ -132,6 +137,10 @@ class AppriseAsset(object):
# that you leave this option as is otherwise. # that you leave this option as is otherwise.
secure_logging = True secure_logging = True
# Optionally specify one or more path to attempt to scan for Python modules
# By default, no paths are scanned.
__plugin_paths = []
# All internal/system flags are prefixed with an underscore (_) # All internal/system flags are prefixed with an underscore (_)
# These can only be initialized using Python libraries and are not picked # These can only be initialized using Python libraries and are not picked
# up from (yaml) configuration files (if set) # up from (yaml) configuration files (if set)
@ -146,7 +155,7 @@ class AppriseAsset(object):
# A unique identifer we can use to associate our calling source # A unique identifer we can use to associate our calling source
_uid = str(uuid4()) _uid = str(uuid4())
def __init__(self, **kwargs): def __init__(self, plugin_paths=None, **kwargs):
""" """
Asset Initialization Asset Initialization
@ -160,6 +169,10 @@ class AppriseAsset(object):
setattr(self, key, value) setattr(self, key, value)
if plugin_paths:
# Load any decorated modules if defined
module_detection(plugin_paths)
def color(self, notify_type, color_type=None): def color(self, notify_type, color_type=None):
""" """
Returns an HTML mapped color based on passed in notify type Returns an HTML mapped color based on passed in notify type

View file

@ -23,18 +23,17 @@
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
# THE SOFTWARE. # THE SOFTWARE.
import six
from . import attachment from . import attachment
from . import URLBase from . import URLBase
from .AppriseAsset import AppriseAsset from .AppriseAsset import AppriseAsset
from .logger import logger from .logger import logger
from .common import ContentLocation from .common import ContentLocation
from .common import CONTENT_LOCATIONS from .common import CONTENT_LOCATIONS
from .common import ATTACHMENT_SCHEMA_MAP
from .utils import GET_SCHEMA_RE from .utils import GET_SCHEMA_RE
class AppriseAttachment(object): class AppriseAttachment:
""" """
Our Apprise Attachment File Manager Our Apprise Attachment File Manager
@ -142,7 +141,7 @@ class AppriseAttachment(object):
self.attachments.append(attachments) self.attachments.append(attachments)
return True return True
elif isinstance(attachments, six.string_types): elif isinstance(attachments, str):
# Save our path # Save our path
attachments = (attachments, ) attachments = (attachments, )
@ -161,7 +160,7 @@ class AppriseAttachment(object):
return_status = False return_status = False
continue continue
if isinstance(_attachment, six.string_types): if isinstance(_attachment, str):
logger.debug("Loading attachment: {}".format(_attachment)) logger.debug("Loading attachment: {}".format(_attachment))
# Instantiate ourselves an object, this function throws or # Instantiate ourselves an object, this function throws or
# returns None if it fails # returns None if it fails
@ -225,13 +224,13 @@ class AppriseAttachment(object):
schema = schema.group('schema').lower() schema = schema.group('schema').lower()
# Some basic validation # Some basic validation
if schema not in attachment.SCHEMA_MAP: if schema not in ATTACHMENT_SCHEMA_MAP:
logger.warning('Unsupported schema {}.'.format(schema)) logger.warning('Unsupported schema {}.'.format(schema))
return None return None
# Parse our url details of the server object as dictionary containing # Parse our url details of the server object as dictionary containing
# all of the information parsed from our URL # all of the information parsed from our URL
results = attachment.SCHEMA_MAP[schema].parse_url(url) results = ATTACHMENT_SCHEMA_MAP[schema].parse_url(url)
if not results: if not results:
# Failed to parse the server URL # Failed to parse the server URL
@ -251,7 +250,7 @@ class AppriseAttachment(object):
# Attempt to create an instance of our plugin using the parsed # Attempt to create an instance of our plugin using the parsed
# URL information # URL information
attach_plugin = \ attach_plugin = \
attachment.SCHEMA_MAP[results['schema']](**results) ATTACHMENT_SCHEMA_MAP[results['schema']](**results)
except Exception: except Exception:
# the arguments are invalid or can not be used. # the arguments are invalid or can not be used.
@ -261,7 +260,7 @@ class AppriseAttachment(object):
else: else:
# Attempt to create an instance of our plugin using the parsed # Attempt to create an instance of our plugin using the parsed
# URL information but don't wrap it in a try catch # URL information but don't wrap it in a try catch
attach_plugin = attachment.SCHEMA_MAP[results['schema']](**results) attach_plugin = ATTACHMENT_SCHEMA_MAP[results['schema']](**results)
return attach_plugin return attach_plugin
@ -295,15 +294,8 @@ class AppriseAttachment(object):
def __bool__(self): def __bool__(self):
""" """
Allows the Apprise object to be wrapped in an Python 3.x based 'if Allows the Apprise object to be wrapped in an 'if statement'.
statement'. True is returned if at least one service has been loaded. True is returned if at least one service has been loaded.
"""
return True if self.attachments 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.attachments else False return True if self.attachments else False

View file

@ -33,6 +33,5 @@ class AppriseAttachment:
def pop(self, index: int = ...) -> AttachBase: ... def pop(self, index: int = ...) -> AttachBase: ...
def __getitem__(self, index: int) -> AttachBase: ... def __getitem__(self, index: int) -> AttachBase: ...
def __bool__(self) -> bool: ... def __bool__(self) -> bool: ...
def __nonzero__(self) -> bool: ...
def __iter__(self) -> Iterator[AttachBase]: ... def __iter__(self) -> Iterator[AttachBase]: ...
def __len__(self) -> int: ... def __len__(self) -> int: ...

View file

@ -23,23 +23,19 @@
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
# THE SOFTWARE. # THE SOFTWARE.
import six
from . import config from . import config
from . import ConfigBase from . import ConfigBase
from . import CONFIG_FORMATS from . import CONFIG_FORMATS
from . import URLBase from . import URLBase
from .AppriseAsset import AppriseAsset from .AppriseAsset import AppriseAsset
from . import common
from .common import MATCH_ALL_TAG
from .common import MATCH_ALWAYS_TAG
from .utils import GET_SCHEMA_RE from .utils import GET_SCHEMA_RE
from .utils import parse_list from .utils import parse_list
from .utils import is_exclusive_match from .utils import is_exclusive_match
from .logger import logger from .logger import logger
class AppriseConfig(object): class AppriseConfig:
""" """
Our Apprise Configuration File Manager Our Apprise Configuration File Manager
@ -171,7 +167,7 @@ class AppriseConfig(object):
self.configs.append(configs) self.configs.append(configs)
return True return True
elif isinstance(configs, six.string_types): elif isinstance(configs, str):
# Save our path # Save our path
configs = (configs, ) configs = (configs, )
@ -189,7 +185,7 @@ class AppriseConfig(object):
self.configs.append(_config) self.configs.append(_config)
continue continue
elif not isinstance(_config, six.string_types): elif not isinstance(_config, str):
logger.warning( logger.warning(
"An invalid configuration (type={}) was specified.".format( "An invalid configuration (type={}) was specified.".format(
type(_config))) type(_config)))
@ -243,7 +239,7 @@ class AppriseConfig(object):
# prepare default asset # prepare default asset
asset = self.asset asset = self.asset
if not isinstance(content, six.string_types): if not isinstance(content, str):
logger.warning( logger.warning(
"An invalid configuration (type={}) was specified.".format( "An invalid configuration (type={}) was specified.".format(
type(content))) type(content)))
@ -267,7 +263,8 @@ class AppriseConfig(object):
# Return our status # Return our status
return True return True
def servers(self, tag=MATCH_ALL_TAG, match_always=True, *args, **kwargs): def servers(self, tag=common.MATCH_ALL_TAG, match_always=True, *args,
**kwargs):
""" """
Returns all of our servers dynamically build based on parsed Returns all of our servers dynamically build based on parsed
configuration. configuration.
@ -285,7 +282,7 @@ class AppriseConfig(object):
# A match_always flag allows us to pick up on our 'any' keyword # A match_always flag allows us to pick up on our 'any' keyword
# and notify these services under all circumstances # and notify these services under all circumstances
match_always = MATCH_ALWAYS_TAG if match_always else None match_always = common.MATCH_ALWAYS_TAG if match_always else None
# Build our tag setup # Build our tag setup
# - top level entries are treated as an 'or' # - top level entries are treated as an 'or'
@ -303,7 +300,7 @@ class AppriseConfig(object):
# Apply our tag matching based on our defined logic # Apply our tag matching based on our defined logic
if is_exclusive_match( if is_exclusive_match(
logic=tag, data=entry.tags, match_all=MATCH_ALL_TAG, logic=tag, data=entry.tags, match_all=common.MATCH_ALL_TAG,
match_always=match_always): match_always=match_always):
# Build ourselves a list of services dynamically and return the # Build ourselves a list of services dynamically and return the
# as a list # as a list
@ -334,13 +331,13 @@ class AppriseConfig(object):
schema = schema.group('schema').lower() schema = schema.group('schema').lower()
# Some basic validation # Some basic validation
if schema not in config.SCHEMA_MAP: if schema not in common.CONFIG_SCHEMA_MAP:
logger.warning('Unsupported schema {}.'.format(schema)) logger.warning('Unsupported schema {}.'.format(schema))
return None return None
# Parse our url details of the server object as dictionary containing # Parse our url details of the server object as dictionary containing
# all of the information parsed from our URL # all of the information parsed from our URL
results = config.SCHEMA_MAP[schema].parse_url(url) results = common.CONFIG_SCHEMA_MAP[schema].parse_url(url)
if not results: if not results:
# Failed to parse the server URL # Failed to parse the server URL
@ -368,7 +365,8 @@ class AppriseConfig(object):
try: try:
# Attempt to create an instance of our plugin using the parsed # Attempt to create an instance of our plugin using the parsed
# URL information # URL information
cfg_plugin = config.SCHEMA_MAP[results['schema']](**results) cfg_plugin = \
common.CONFIG_SCHEMA_MAP[results['schema']](**results)
except Exception: except Exception:
# the arguments are invalid or can not be used. # the arguments are invalid or can not be used.
@ -378,7 +376,7 @@ class AppriseConfig(object):
else: else:
# Attempt to create an instance of our plugin using the parsed # Attempt to create an instance of our plugin using the parsed
# URL information but don't wrap it in a try catch # URL information but don't wrap it in a try catch
cfg_plugin = config.SCHEMA_MAP[results['schema']](**results) cfg_plugin = common.CONFIG_SCHEMA_MAP[results['schema']](**results)
return cfg_plugin return cfg_plugin
@ -432,15 +430,8 @@ class AppriseConfig(object):
def __bool__(self): def __bool__(self):
""" """
Allows the Apprise object to be wrapped in an Python 3.x based 'if Allows the Apprise object to be wrapped in an 'if statement'.
statement'. True is returned if at least one service has been loaded. 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 return True if self.configs else False

View file

@ -44,6 +44,5 @@ class AppriseConfig:
def pop(self, index: int = ...) -> ConfigBase: ... def pop(self, index: int = ...) -> ConfigBase: ...
def __getitem__(self, index: int) -> ConfigBase: ... def __getitem__(self, index: int) -> ConfigBase: ...
def __bool__(self) -> bool: ... def __bool__(self) -> bool: ...
def __nonzero__(self) -> bool: ...
def __iter__(self) -> Iterator[ConfigBase]: ... def __iter__(self) -> Iterator[ConfigBase]: ...
def __len__(self) -> int: ... def __len__(self) -> int: ...

View file

@ -23,7 +23,6 @@
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
# THE SOFTWARE. # THE SOFTWARE.
import six
import ctypes import ctypes
import locale import locale
import contextlib import contextlib
@ -52,18 +51,11 @@ try:
except ImportError: except ImportError:
# gettext isn't available; no problem, just fall back to using # gettext isn't available; no problem, just fall back to using
# the library features without multi-language support. # the library features without multi-language support.
try: import builtins
# Python v2.7 builtins.__dict__['_'] = lambda x: x # pragma: no branch
import __builtin__
__builtin__.__dict__['_'] = lambda x: x # pragma: no branch
except ImportError:
# Python v3.4+
import builtins
builtins.__dict__['_'] = lambda x: x # pragma: no branch
class LazyTranslation(object): class LazyTranslation:
""" """
Doesn't translate anything until str() or unicode() references Doesn't translate anything until str() or unicode() references
are made. are made.
@ -89,7 +81,7 @@ def gettext_lazy(text):
return LazyTranslation(text=text) return LazyTranslation(text=text)
class AppriseLocale(object): class AppriseLocale:
""" """
A wrapper class to gettext so that we can manipulate multiple lanaguages A wrapper class to gettext so that we can manipulate multiple lanaguages
on the fly if required. on the fly if required.
@ -186,7 +178,7 @@ class AppriseLocale(object):
""" """
# We want to only use the 2 character version of this language # We want to only use the 2 character version of this language
# hence en_CA becomes en, en_US becomes en. # hence en_CA becomes en, en_US becomes en.
if not isinstance(lang, six.string_types): if not isinstance(lang, str):
if detect_fallback is False: if detect_fallback is False:
# no detection enabled; we're done # no detection enabled; we're done
return None return None

View file

@ -24,26 +24,17 @@
# THE SOFTWARE. # THE SOFTWARE.
import re import re
import six
from .logger import logger from .logger import logger
from time import sleep from time import sleep
from datetime import datetime from datetime import datetime
from xml.sax.saxutils import escape as sax_escape from xml.sax.saxutils import escape as sax_escape
try: from urllib.parse import unquote as _unquote
# Python 2.7 from urllib.parse import quote as _quote
from urllib import unquote as _unquote
from urllib import quote as _quote
from urllib import urlencode as _urlencode
except ImportError:
# Python 3.x
from urllib.parse import unquote as _unquote
from urllib.parse import quote as _quote
from urllib.parse import urlencode as _urlencode
from .AppriseLocale import gettext_lazy as _ from .AppriseLocale import gettext_lazy as _
from .AppriseAsset import AppriseAsset from .AppriseAsset import AppriseAsset
from .utils import urlencode
from .utils import parse_url from .utils import parse_url
from .utils import parse_bool from .utils import parse_bool
from .utils import parse_list from .utils import parse_list
@ -53,7 +44,7 @@ from .utils import parse_phone_no
PATHSPLIT_LIST_DELIM = re.compile(r'[ \t\r\n,\\/]+') PATHSPLIT_LIST_DELIM = re.compile(r'[ \t\r\n,\\/]+')
class PrivacyMode(object): class PrivacyMode:
# Defines different privacy modes strings can be printed as # Defines different privacy modes strings can be printed as
# Astrisk sets 4 of them: e.g. **** # Astrisk sets 4 of them: e.g. ****
# This is used for passwords # This is used for passwords
@ -78,7 +69,7 @@ HTML_LOOKUP = {
} }
class URLBase(object): class URLBase:
""" """
This is the base class for all URL Manipulation This is the base class for all URL Manipulation
""" """
@ -346,7 +337,7 @@ class URLBase(object):
Returns: Returns:
str: The escaped html str: The escaped html
""" """
if not isinstance(html, six.string_types) or not html: if not isinstance(html, str) or not html:
return '' return ''
# Escape HTML # Escape HTML
@ -359,7 +350,7 @@ class URLBase(object):
.replace(u' ', u' ') .replace(u' ', u' ')
if convert_new_lines: if convert_new_lines:
return escaped.replace(u'\n', u'&lt;br/&gt;') return escaped.replace(u'\n', u'<br/>')
return escaped return escaped
@ -370,7 +361,7 @@ class URLBase(object):
encoding and errors parameters specify how to decode percent-encoded encoding and errors parameters specify how to decode percent-encoded
sequences. sequences.
Wrapper to Python's unquote while remaining compatible with both Wrapper to Python's `unquote` while remaining compatible with both
Python 2 & 3 since the reference to this function changed between Python 2 & 3 since the reference to this function changed between
versions. versions.
@ -389,20 +380,14 @@ class URLBase(object):
if not content: if not content:
return '' return ''
try: return _unquote(content, encoding=encoding, errors=errors)
# Python v3.x
return _unquote(content, encoding=encoding, errors=errors)
except TypeError:
# Python v2.7
return _unquote(content)
@staticmethod @staticmethod
def quote(content, safe='/', encoding=None, errors=None): def quote(content, safe='/', encoding=None, errors=None):
""" Replaces single character non-ascii characters and URI specific """ Replaces single character non-ascii characters and URI specific
ones by their %xx code. ones by their %xx code.
Wrapper to Python's unquote while remaining compatible with both Wrapper to Python's `quote` while remaining compatible with both
Python 2 & 3 since the reference to this function changed between Python 2 & 3 since the reference to this function changed between
versions. versions.
@ -422,13 +407,7 @@ class URLBase(object):
if not content: if not content:
return '' return ''
try: return _quote(content, safe=safe, encoding=encoding, errors=errors)
# Python v3.x
return _quote(content, safe=safe, encoding=encoding, errors=errors)
except TypeError:
# Python v2.7
return _quote(content, safe=safe)
@staticmethod @staticmethod
def pprint(content, privacy=True, mode=PrivacyMode.Outer, def pprint(content, privacy=True, mode=PrivacyMode.Outer,
@ -457,7 +436,7 @@ class URLBase(object):
# Return 4 Asterisks # Return 4 Asterisks
return '****' return '****'
if not isinstance(content, six.string_types) or not content: if not isinstance(content, str) or not content:
# Nothing more to do # Nothing more to do
return '' return ''
@ -472,7 +451,7 @@ class URLBase(object):
def urlencode(query, doseq=False, safe='', encoding=None, errors=None): def urlencode(query, doseq=False, safe='', encoding=None, errors=None):
"""Convert a mapping object or a sequence of two-element tuples """Convert a mapping object or a sequence of two-element tuples
Wrapper to Python's unquote while remaining compatible with both Wrapper to Python's `urlencode` while remaining compatible with both
Python 2 & 3 since the reference to this function changed between Python 2 & 3 since the reference to this function changed between
versions. versions.
@ -497,17 +476,8 @@ class URLBase(object):
Returns: Returns:
str: The escaped parameters returned as a string str: The escaped parameters returned as a string
""" """
# Tidy query by eliminating any records set to None return urlencode(
_query = {k: v for (k, v) in query.items() if v is not None} query, doseq=doseq, safe=safe, encoding=encoding, errors=errors)
try:
# Python v3.x
return _urlencode(
_query, doseq=doseq, safe=safe, encoding=encoding,
errors=errors)
except TypeError:
# Python v2.7
return _urlencode(_query)
@staticmethod @staticmethod
def split_path(path, unquote=True): def split_path(path, unquote=True):
@ -585,11 +555,6 @@ class URLBase(object):
# Nothing further to do # Nothing further to do
return [] return []
except AttributeError:
# This exception ONLY gets thrown under Python v2.7 if an
# object() is passed in place of the content
return []
content = parse_phone_no(content) content = parse_phone_no(content)
return content return content
@ -687,6 +652,9 @@ class URLBase(object):
if 'cto' in results['qsd']: if 'cto' in results['qsd']:
results['socket_connect_timeout'] = results['qsd']['cto'] results['socket_connect_timeout'] = results['qsd']['cto']
if 'port' in results['qsd']:
results['port'] = results['qsd']['port']
return results return results
@staticmethod @staticmethod
@ -721,13 +689,13 @@ class URLBase(object):
for key in ('protocol', 'secure_protocol'): for key in ('protocol', 'secure_protocol'):
schema = getattr(self, key, None) schema = getattr(self, key, None)
if isinstance(schema, six.string_types): if isinstance(schema, str):
schemas.add(schema) schemas.add(schema)
elif isinstance(schema, (set, list, tuple)): elif isinstance(schema, (set, list, tuple)):
# Support iterables list types # Support iterables list types
for s in schema: for s in schema:
if isinstance(s, six.string_types): if isinstance(s, str):
schemas.add(s) schemas.add(s)
return schemas return schemas

View file

@ -24,7 +24,7 @@
# THE SOFTWARE. # THE SOFTWARE.
__title__ = 'Apprise' __title__ = 'Apprise'
__version__ = '0.9.8.3' __version__ = '1.1.0'
__author__ = 'Chris Caron' __author__ = 'Chris Caron'
__license__ = 'MIT' __license__ = 'MIT'
__copywrite__ = 'Copyright (C) 2022 Chris Caron <lead2gold@gmail.com>' __copywrite__ = 'Copyright (C) 2022 Chris Caron <lead2gold@gmail.com>'
@ -57,6 +57,8 @@ from .AppriseAsset import AppriseAsset
from .AppriseConfig import AppriseConfig from .AppriseConfig import AppriseConfig
from .AppriseAttachment import AppriseAttachment from .AppriseAttachment import AppriseAttachment
from . import decorators
# Inherit our logging with our additional entries added to it # Inherit our logging with our additional entries added to it
from .logger import logging from .logger import logging
from .logger import logger from .logger import logger
@ -78,6 +80,9 @@ __all__ = [
'ContentLocation', 'CONTENT_LOCATIONS', 'ContentLocation', 'CONTENT_LOCATIONS',
'PrivacyMode', 'PrivacyMode',
# Decorator
'decorators',
# Logging # Logging
'logging', 'logger', 'LogCapture', 'logging', 'logger', 'LogCapture',
] ]

View file

@ -367,14 +367,7 @@ class AttachBase(URLBase):
def __bool__(self): def __bool__(self):
""" """
Allows the Apprise object to be wrapped in an Python 3.x based 'if Allows the Apprise object to be wrapped in an based 'if statement'.
statement'. True is returned if our content was downloaded correctly. True is returned if our content was downloaded correctly.
"""
return True if self.path 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.
""" """
return True if self.path else False return True if self.path else False

View file

@ -34,4 +34,3 @@ class AttachBase:
) -> Dict[str, Any]: ... ) -> Dict[str, Any]: ...
def __len__(self) -> int: ... def __len__(self) -> int: ...
def __bool__(self) -> bool: ... def __bool__(self) -> bool: ...
def __nonzero__(self) -> bool: ...

View file

@ -25,7 +25,6 @@
import re import re
import os import os
import six
import requests import requests
from tempfile import NamedTemporaryFile from tempfile import NamedTemporaryFile
from .AttachBase import AttachBase from .AttachBase import AttachBase
@ -67,7 +66,7 @@ class AttachHTTP(AttachBase):
self.schema = 'https' if self.secure else 'http' self.schema = 'https' if self.secure else 'http'
self.fullpath = kwargs.get('fullpath') self.fullpath = kwargs.get('fullpath')
if not isinstance(self.fullpath, six.string_types): if not isinstance(self.fullpath, str):
self.fullpath = '/' self.fullpath = '/'
self.headers = {} self.headers = {}

View file

@ -23,15 +23,12 @@
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
# THE SOFTWARE. # THE SOFTWARE.
import six
import re import re
from os import listdir from os import listdir
from os.path import dirname from os.path import dirname
from os.path import abspath from os.path import abspath
from ..common import ATTACHMENT_SCHEMA_MAP
# Maintains a mapping of all of the attachment services
SCHEMA_MAP = {}
__all__ = [] __all__ = []
@ -90,29 +87,29 @@ def __load_matrix(path=abspath(dirname(__file__)), name='apprise.attachment'):
# Load protocol(s) if defined # Load protocol(s) if defined
proto = getattr(plugin, 'protocol', None) proto = getattr(plugin, 'protocol', None)
if isinstance(proto, six.string_types): if isinstance(proto, str):
if proto not in SCHEMA_MAP: if proto not in ATTACHMENT_SCHEMA_MAP:
SCHEMA_MAP[proto] = plugin ATTACHMENT_SCHEMA_MAP[proto] = plugin
elif isinstance(proto, (set, list, tuple)): elif isinstance(proto, (set, list, tuple)):
# Support iterables list types # Support iterables list types
for p in proto: for p in proto:
if p not in SCHEMA_MAP: if p not in ATTACHMENT_SCHEMA_MAP:
SCHEMA_MAP[p] = plugin ATTACHMENT_SCHEMA_MAP[p] = plugin
# Load secure protocol(s) if defined # Load secure protocol(s) if defined
protos = getattr(plugin, 'secure_protocol', None) protos = getattr(plugin, 'secure_protocol', None)
if isinstance(protos, six.string_types): if isinstance(protos, str):
if protos not in SCHEMA_MAP: if protos not in ATTACHMENT_SCHEMA_MAP:
SCHEMA_MAP[protos] = plugin ATTACHMENT_SCHEMA_MAP[protos] = plugin
if isinstance(protos, (set, list, tuple)): if isinstance(protos, (set, list, tuple)):
# Support iterables list types # Support iterables list types
for p in protos: for p in protos:
if p not in SCHEMA_MAP: if p not in ATTACHMENT_SCHEMA_MAP:
SCHEMA_MAP[p] = plugin ATTACHMENT_SCHEMA_MAP[p] = plugin
return SCHEMA_MAP return ATTACHMENT_SCHEMA_MAP
# Dynamically build our schema base # Dynamically build our schema base

View file

@ -26,12 +26,12 @@
import click import click
import logging import logging
import platform import platform
import six
import sys import sys
import os import os
import re import re
from os.path import isfile from os.path import isfile
from os.path import exists
from os.path import expanduser from os.path import expanduser
from os.path import expandvars from os.path import expandvars
@ -40,6 +40,7 @@ from . import NotifyFormat
from . import Apprise from . import Apprise
from . import AppriseAsset from . import AppriseAsset
from . import AppriseConfig from . import AppriseConfig
from .utils import parse_list from .utils import parse_list
from .common import NOTIFY_TYPES from .common import NOTIFY_TYPES
from .common import NOTIFY_FORMATS from .common import NOTIFY_FORMATS
@ -60,23 +61,42 @@ DEFAULT_RECURSION_DEPTH = 1
CONTEXT_SETTINGS = dict(help_option_names=['-h', '--help']) CONTEXT_SETTINGS = dict(help_option_names=['-h', '--help'])
# Define our default configuration we use if nothing is otherwise specified # Define our default configuration we use if nothing is otherwise specified
DEFAULT_SEARCH_PATHS = ( DEFAULT_CONFIG_PATHS = (
# Legacy Path Support
'~/.apprise', '~/.apprise',
'~/.apprise.yml', '~/.apprise.yml',
'~/.config/apprise', '~/.config/apprise',
'~/.config/apprise.yml', '~/.config/apprise.yml',
# Plugin Support Extended Directory Search Paths
'~/.apprise/apprise',
'~/.apprise/apprise.yml',
'~/.config/apprise/apprise',
'~/.config/apprise/apprise.yml',
)
# Define our paths to search for plugins
DEFAULT_PLUGIN_PATHS = (
'~/.apprise/plugins',
'~/.config/apprise/plugins',
) )
# Detect Windows # Detect Windows
if platform.system() == 'Windows': if platform.system() == 'Windows':
# Default Search Path for Windows Users # Default Config Search Path for Windows Users
DEFAULT_SEARCH_PATHS = ( DEFAULT_CONFIG_PATHS = (
expandvars('%APPDATA%/Apprise/apprise'), expandvars('%APPDATA%/Apprise/apprise'),
expandvars('%APPDATA%/Apprise/apprise.yml'), expandvars('%APPDATA%/Apprise/apprise.yml'),
expandvars('%LOCALAPPDATA%/Apprise/apprise'), expandvars('%LOCALAPPDATA%/Apprise/apprise'),
expandvars('%LOCALAPPDATA%/Apprise/apprise.yml'), expandvars('%LOCALAPPDATA%/Apprise/apprise.yml'),
) )
# Default Plugin Search Path for Windows Users
DEFAULT_PLUGIN_PATHS = (
expandvars('%APPDATA%/Apprise/plugins'),
expandvars('%LOCALAPPDATA%/Apprise/plugins'),
)
def print_help_msg(command): def print_help_msg(command):
""" """
@ -107,6 +127,9 @@ def print_version_msg():
@click.option('--title', '-t', default=None, type=str, @click.option('--title', '-t', default=None, type=str,
help='Specify the message title. This field is complete ' help='Specify the message title. This field is complete '
'optional.') 'optional.')
@click.option('--plugin-path', '-P', default=None, type=str, multiple=True,
metavar='PLUGIN_PATH',
help='Specify one or more plugin paths to scan.')
@click.option('--config', '-c', default=None, type=str, multiple=True, @click.option('--config', '-c', default=None, type=str, multiple=True,
metavar='CONFIG_URL', metavar='CONFIG_URL',
help='Specify one or more configuration locations.') help='Specify one or more configuration locations.')
@ -158,7 +181,7 @@ def print_version_msg():
metavar='SERVER_URL [SERVER_URL2 [SERVER_URL3]]',) metavar='SERVER_URL [SERVER_URL2 [SERVER_URL3]]',)
def main(body, title, config, attach, urls, notification_type, theme, tag, def main(body, title, config, attach, urls, notification_type, theme, tag,
input_format, dry_run, recursion_depth, verbose, disable_async, input_format, dry_run, recursion_depth, verbose, disable_async,
details, interpret_escapes, debug, version): details, interpret_escapes, plugin_path, debug, version):
""" """
Send a notification to all of the specified servers identified by their Send a notification to all of the specified servers identified by their
URLs the content provided within the title, body and notification-type. URLs the content provided within the title, body and notification-type.
@ -232,6 +255,12 @@ def main(body, title, config, attach, urls, notification_type, theme, tag,
# issue. For consistency, we also return a 2 # issue. For consistency, we also return a 2
sys.exit(2) sys.exit(2)
if not plugin_path:
# Prepare a default set of plugin path
plugin_path = \
next((path for path in DEFAULT_PLUGIN_PATHS
if exists(expanduser(path))), None)
# Prepare our asset # Prepare our asset
asset = AppriseAsset( asset = AppriseAsset(
# Our body format # Our body format
@ -243,11 +272,14 @@ def main(body, title, config, attach, urls, notification_type, theme, tag,
# Set the theme # Set the theme
theme=theme, theme=theme,
# Async mode is only used for Python v3+ and allows a user to send # Async mode allows a user to send all of their notifications
# all of their notifications asyncronously. This was made an option # asynchronously. This was made an option incase there are problems
# incase there are problems in the future where it's better that # in the future where it is better that everything runs sequentially/
# everything run sequentially/syncronously instead. # synchronously instead.
async_mode=disable_async is not True, async_mode=disable_async is not True,
# Load our plugins
plugin_paths=plugin_path,
) )
# Create our Apprise object # Create our Apprise object
@ -263,11 +295,11 @@ def main(body, title, config, attach, urls, notification_type, theme, tag,
for entry in plugins: for entry in plugins:
protocols = [] if not entry['protocols'] else \ protocols = [] if not entry['protocols'] else \
[p for p in entry['protocols'] [p for p in entry['protocols']
if isinstance(p, six.string_types)] if isinstance(p, str)]
protocols.extend( protocols.extend(
[] if not entry['secure_protocols'] else [] if not entry['secure_protocols'] else
[p for p in entry['secure_protocols'] [p for p in entry['secure_protocols']
if isinstance(p, six.string_types)]) if isinstance(p, str)])
if len(protocols) == 1: if len(protocols) == 1:
# Simplify view by swapping {schema} with the single # Simplify view by swapping {schema} with the single
@ -284,11 +316,18 @@ def main(body, title, config, attach, urls, notification_type, theme, tag,
'{}://'.format(protocols[0]), '{}://'.format(protocols[0]),
entry['details']['templates'][x]) entry['details']['templates'][x])
fg = "green" if entry['enabled'] else "red"
if entry['category'] == 'custom':
# Identify these differently
fg = "cyan"
# Flip the enable switch so it forces the requirements
# to be displayed
entry['enabled'] = False
click.echo(click.style( click.echo(click.style(
'{} {:<30} '.format( '{} {:<30} '.format(
'+' if entry['enabled'] else '-', '+' if entry['enabled'] else '-',
str(entry['service_name'])), str(entry['service_name'])), fg=fg, bold=True),
fg="green" if entry['enabled'] else "red", bold=True),
nl=(not entry['enabled'] or len(protocols) == 1)) nl=(not entry['enabled'] or len(protocols) == 1))
if not entry['enabled']: if not entry['enabled']:
@ -307,8 +346,9 @@ def main(body, title, config, attach, urls, notification_type, theme, tag,
click.echo(' - ' + req) click.echo(' - ' + req)
# new line padding between entries # new line padding between entries
click.echo() if entry['category'] == 'native':
continue click.echo()
continue
if len(protocols) > 1: if len(protocols) > 1:
click.echo('| Schema(s): {}'.format( click.echo('| Schema(s): {}'.format(
@ -324,6 +364,7 @@ def main(body, title, config, attach, urls, notification_type, theme, tag,
click.echo() click.echo()
sys.exit(0) sys.exit(0)
# end if details()
# The priorities of what is accepted are parsed in order below: # The priorities of what is accepted are parsed in order below:
# 1. URLs by command line # 1. URLs by command line
@ -372,13 +413,14 @@ def main(body, title, config, attach, urls, notification_type, theme, tag,
a.add(AppriseConfig( a.add(AppriseConfig(
paths=os.environ['APPRISE_CONFIG'].strip(), paths=os.environ['APPRISE_CONFIG'].strip(),
asset=asset, recursion=recursion_depth)) asset=asset, recursion=recursion_depth))
else: else:
# Load default configuration # Load default configuration
a.add(AppriseConfig( a.add(AppriseConfig(
paths=[f for f in DEFAULT_SEARCH_PATHS if isfile(expanduser(f))], paths=[f for f in DEFAULT_CONFIG_PATHS if isfile(expanduser(f))],
asset=asset, recursion=recursion_depth)) asset=asset, recursion=recursion_depth))
if len(a) == 0: if len(a) == 0 and not urls:
logger.error( logger.error(
'You must specify at least one server URL or populated ' 'You must specify at least one server URL or populated '
'configuration file.') 'configuration file.')

View file

@ -24,7 +24,52 @@
# THE SOFTWARE. # THE SOFTWARE.
class NotifyType(object): # we mirror our base purely for the ability to reset everything; this
# is generally only used in testing and should not be used by developers
# It is also used as a means of preventing a module from being reloaded
# in the event it already exists
NOTIFY_MODULE_MAP = {}
# Maintains a mapping of all of the Notification services
NOTIFY_SCHEMA_MAP = {}
# This contains a mapping of all plugins dynamicaly loaded at runtime from
# external modules such as the @notify decorator
#
# The elements here will be additionally added to the NOTIFY_SCHEMA_MAP if
# there is no conflict otherwise.
# The structure looks like the following:
# Module path, e.g. /usr/share/apprise/plugins/my_notify_hook.py
# {
# 'path': path,
#
# 'notify': {
# 'schema': {
# 'name': 'Custom schema name',
# 'fn_name': 'name_of_function_decorator_was_found_on',
# 'url': 'schema://any/additional/info/found/on/url'
# 'plugin': <CustomNotifyWrapperPlugin>
# },
# 'schema2': {
# 'name': 'Custom schema name',
# 'fn_name': 'name_of_function_decorator_was_found_on',
# 'url': 'schema://any/additional/info/found/on/url'
# 'plugin': <CustomNotifyWrapperPlugin>
# }
# }
#
# Note: that the <CustomNotifyWrapperPlugin> inherits from
# NotifyBase
NOTIFY_CUSTOM_MODULE_MAP = {}
# Maintains a mapping of all configuration schema's supported
CONFIG_SCHEMA_MAP = {}
# Maintains a mapping of all attachment schema's supported
ATTACHMENT_SCHEMA_MAP = {}
class NotifyType:
""" """
A simple mapping of notification types most commonly used with A simple mapping of notification types most commonly used with
all types of logging and notification services. all types of logging and notification services.
@ -43,7 +88,7 @@ NOTIFY_TYPES = (
) )
class NotifyImageSize(object): class NotifyImageSize:
""" """
A list of pre-defined image sizes to make it easier to work with defined A list of pre-defined image sizes to make it easier to work with defined
plugins. plugins.
@ -62,7 +107,7 @@ NOTIFY_IMAGE_SIZES = (
) )
class NotifyFormat(object): class NotifyFormat:
""" """
A list of pre-defined text message formats that can be passed via the A list of pre-defined text message formats that can be passed via the
apprise library. apprise library.
@ -79,7 +124,7 @@ NOTIFY_FORMATS = (
) )
class OverflowMode(object): class OverflowMode:
""" """
A list of pre-defined modes of how to handle the text when it exceeds the A list of pre-defined modes of how to handle the text when it exceeds the
defined maximum message size. defined maximum message size.
@ -107,7 +152,7 @@ OVERFLOW_MODES = (
) )
class ConfigFormat(object): class ConfigFormat:
""" """
A list of pre-defined config formats that can be passed via the A list of pre-defined config formats that can be passed via the
apprise library. apprise library.
@ -130,7 +175,7 @@ CONFIG_FORMATS = (
) )
class ContentIncludeMode(object): class ContentIncludeMode:
""" """
The different Content inclusion modes. All content based plugins will The different Content inclusion modes. All content based plugins will
have one of these associated with it. have one of these associated with it.
@ -155,7 +200,7 @@ CONTENT_INCLUDE_MODES = (
) )
class ContentLocation(object): class ContentLocation:
""" """
This is primarily used for handling file attachments. The idea is This is primarily used for handling file attachments. The idea is
to track the source of the attachment itself. We don't want to track the source of the attachment itself. We don't want

View file

@ -25,22 +25,18 @@
import os import os
import re import re
import six
import yaml import yaml
import time import time
from .. import plugins from .. import plugins
from .. import common
from ..AppriseAsset import AppriseAsset from ..AppriseAsset import AppriseAsset
from ..URLBase import URLBase from ..URLBase import URLBase
from ..common import ConfigFormat
from ..common import CONFIG_FORMATS
from ..common import ContentIncludeMode
from ..utils import GET_SCHEMA_RE from ..utils import GET_SCHEMA_RE
from ..utils import parse_list from ..utils import parse_list
from ..utils import parse_bool from ..utils import parse_bool
from ..utils import parse_urls from ..utils import parse_urls
from ..utils import cwe312_url from ..utils import cwe312_url
from . import SCHEMA_MAP
# Test whether token is valid or not # Test whether token is valid or not
VALID_TOKEN = re.compile( VALID_TOKEN = re.compile(
@ -57,7 +53,7 @@ class ConfigBase(URLBase):
# The default expected configuration format unless otherwise # The default expected configuration format unless otherwise
# detected by the sub-modules # detected by the sub-modules
default_config_format = ConfigFormat.TEXT default_config_format = common.ConfigFormat.TEXT
# This is only set if the user overrides the config format on the URL # This is only set if the user overrides the config format on the URL
# this should always initialize itself as None # this should always initialize itself as None
@ -70,7 +66,7 @@ class ConfigBase(URLBase):
# By default all configuration is not includable using the 'include' # By default all configuration is not includable using the 'include'
# line found in configuration files. # line found in configuration files.
allow_cross_includes = ContentIncludeMode.NEVER allow_cross_includes = common.ContentIncludeMode.NEVER
# the config path manages the handling of relative include # the config path manages the handling of relative include
config_path = os.getcwd() config_path = os.getcwd()
@ -138,11 +134,11 @@ class ConfigBase(URLBase):
self.encoding = kwargs.get('encoding') self.encoding = kwargs.get('encoding')
if 'format' in kwargs \ if 'format' in kwargs \
and isinstance(kwargs['format'], six.string_types): and isinstance(kwargs['format'], str):
# Store the enforced config format # Store the enforced config format
self.config_format = kwargs.get('format').lower() self.config_format = kwargs.get('format').lower()
if self.config_format not in CONFIG_FORMATS: if self.config_format not in common.CONFIG_FORMATS:
# Simple error checking # Simple error checking
err = 'An invalid config format ({}) was specified.'.format( err = 'An invalid config format ({}) was specified.'.format(
self.config_format) self.config_format)
@ -183,7 +179,7 @@ class ConfigBase(URLBase):
# config plugin to load the data source and return unparsed content # config plugin to load the data source and return unparsed content
# None is returned if there was an error or simply no data # None is returned if there was an error or simply no data
content = self.read(**kwargs) content = self.read(**kwargs)
if not isinstance(content, six.string_types): if not isinstance(content, str):
# Set the time our content was cached at # Set the time our content was cached at
self._cached_time = time.time() self._cached_time = time.time()
@ -230,7 +226,7 @@ class ConfigBase(URLBase):
schema = schema.group('schema').lower() schema = schema.group('schema').lower()
# Some basic validation # Some basic validation
if schema not in SCHEMA_MAP: if schema not in common.CONFIG_SCHEMA_MAP:
ConfigBase.logger.warning( ConfigBase.logger.warning(
'Unsupported include schema {}.'.format(schema)) 'Unsupported include schema {}.'.format(schema))
continue continue
@ -241,7 +237,7 @@ class ConfigBase(URLBase):
# Parse our url details of the server object as dictionary # Parse our url details of the server object as dictionary
# containing all of the information parsed from our URL # containing all of the information parsed from our URL
results = SCHEMA_MAP[schema].parse_url(url) results = common.CONFIG_SCHEMA_MAP[schema].parse_url(url)
if not results: if not results:
# Failed to parse the server URL # Failed to parse the server URL
self.logger.warning( self.logger.warning(
@ -249,12 +245,13 @@ class ConfigBase(URLBase):
continue continue
# Handle cross inclusion based on allow_cross_includes rules # Handle cross inclusion based on allow_cross_includes rules
if (SCHEMA_MAP[schema].allow_cross_includes == if (common.CONFIG_SCHEMA_MAP[schema].allow_cross_includes ==
ContentIncludeMode.STRICT common.ContentIncludeMode.STRICT
and schema not in self.schemas() and schema not in self.schemas()
and not self.insecure_includes) or \ and not self.insecure_includes) or \
SCHEMA_MAP[schema].allow_cross_includes == \ common.CONFIG_SCHEMA_MAP[schema] \
ContentIncludeMode.NEVER: .allow_cross_includes == \
common.ContentIncludeMode.NEVER:
# Prevent the loading if insecure base protocols # Prevent the loading if insecure base protocols
ConfigBase.logger.warning( ConfigBase.logger.warning(
@ -280,7 +277,8 @@ class ConfigBase(URLBase):
try: try:
# Attempt to create an instance of our plugin using the # Attempt to create an instance of our plugin using the
# parsed URL information # parsed URL information
cfg_plugin = SCHEMA_MAP[results['schema']](**results) cfg_plugin = \
common.CONFIG_SCHEMA_MAP[results['schema']](**results)
except Exception as e: except Exception as e:
# the arguments are invalid or can not be used. # the arguments are invalid or can not be used.
@ -379,7 +377,7 @@ class ConfigBase(URLBase):
# Allow overriding the default config format # Allow overriding the default config format
if 'format' in results['qsd']: if 'format' in results['qsd']:
results['format'] = results['qsd'].get('format') results['format'] = results['qsd'].get('format')
if results['format'] not in CONFIG_FORMATS: if results['format'] not in common.CONFIG_FORMATS:
URLBase.logger.warning( URLBase.logger.warning(
'Unsupported format specified {}'.format( 'Unsupported format specified {}'.format(
results['format'])) results['format']))
@ -457,14 +455,14 @@ class ConfigBase(URLBase):
# Attempt to detect configuration # Attempt to detect configuration
if result.group('yaml'): if result.group('yaml'):
config_format = ConfigFormat.YAML config_format = common.ConfigFormat.YAML
ConfigBase.logger.debug( ConfigBase.logger.debug(
'Detected YAML configuration ' 'Detected YAML configuration '
'based on line {}.'.format(line)) 'based on line {}.'.format(line))
break break
elif result.group('text'): elif result.group('text'):
config_format = ConfigFormat.TEXT config_format = common.ConfigFormat.TEXT
ConfigBase.logger.debug( ConfigBase.logger.debug(
'Detected TEXT configuration ' 'Detected TEXT configuration '
'based on line {}.'.format(line)) 'based on line {}.'.format(line))
@ -472,7 +470,7 @@ class ConfigBase(URLBase):
# If we reach here, we have a comment entry # If we reach here, we have a comment entry
# Adjust default format to TEXT # Adjust default format to TEXT
config_format = ConfigFormat.TEXT config_format = common.ConfigFormat.TEXT
return config_format return config_format
@ -493,7 +491,7 @@ class ConfigBase(URLBase):
ConfigBase.logger.error('Could not detect configuration') ConfigBase.logger.error('Could not detect configuration')
return (list(), list()) return (list(), list())
if config_format not in CONFIG_FORMATS: if config_format not in common.CONFIG_FORMATS:
# Invalid configuration type specified # Invalid configuration type specified
ConfigBase.logger.error( ConfigBase.logger.error(
'An invalid configuration format ({}) was specified'.format( 'An invalid configuration format ({}) was specified'.format(
@ -618,7 +616,7 @@ class ConfigBase(URLBase):
try: try:
# Attempt to create an instance of our plugin using the # Attempt to create an instance of our plugin using the
# parsed URL information # parsed URL information
plugin = plugins.SCHEMA_MAP[results['schema']](**results) plugin = common.NOTIFY_SCHEMA_MAP[results['schema']](**results)
# Create log entry of loaded URL # Create log entry of loaded URL
ConfigBase.logger.debug( ConfigBase.logger.debug(
@ -705,7 +703,7 @@ class ConfigBase(URLBase):
if not (hasattr(asset, k) and if not (hasattr(asset, k) and
isinstance(getattr(asset, k), isinstance(getattr(asset, k),
(bool, six.string_types))): (bool, str))):
# We can't set a function or non-string set value # We can't set a function or non-string set value
ConfigBase.logger.warning( ConfigBase.logger.warning(
@ -716,7 +714,7 @@ class ConfigBase(URLBase):
# Convert to an empty string # Convert to an empty string
v = '' v = ''
if (isinstance(v, (bool, six.string_types)) if (isinstance(v, (bool, str))
and isinstance(getattr(asset, k), bool)): and isinstance(getattr(asset, k), bool)):
# If the object in the Asset is a boolean, then # If the object in the Asset is a boolean, then
@ -724,7 +722,7 @@ class ConfigBase(URLBase):
# match that. # match that.
setattr(asset, k, parse_bool(v)) setattr(asset, k, parse_bool(v))
elif isinstance(v, six.string_types): elif isinstance(v, str):
# Set our asset object with the new value # Set our asset object with the new value
setattr(asset, k, v.strip()) setattr(asset, k, v.strip())
@ -739,7 +737,7 @@ class ConfigBase(URLBase):
global_tags = set() global_tags = set()
tags = result.get('tag', None) tags = result.get('tag', None)
if tags and isinstance(tags, (list, tuple, six.string_types)): if tags and isinstance(tags, (list, tuple, str)):
# Store any preset tags # Store any preset tags
global_tags = set(parse_list(tags)) global_tags = set(parse_list(tags))
@ -747,7 +745,7 @@ class ConfigBase(URLBase):
# include root directive # include root directive
# #
includes = result.get('include', None) includes = result.get('include', None)
if isinstance(includes, six.string_types): if isinstance(includes, str):
# Support a single inline string or multiple ones separated by a # Support a single inline string or multiple ones separated by a
# comma and/or space # comma and/or space
includes = parse_urls(includes) includes = parse_urls(includes)
@ -759,7 +757,7 @@ class ConfigBase(URLBase):
# Iterate over each config URL # Iterate over each config URL
for no, url in enumerate(includes): for no, url in enumerate(includes):
if isinstance(url, six.string_types): if isinstance(url, str):
# Support a single inline string or multiple ones separated by # Support a single inline string or multiple ones separated by
# a comma and/or space # a comma and/or space
configs.extend(parse_urls(url)) configs.extend(parse_urls(url))
@ -787,7 +785,7 @@ class ConfigBase(URLBase):
loggable_url = url if not asset.secure_logging \ loggable_url = url if not asset.secure_logging \
else cwe312_url(url) else cwe312_url(url)
if isinstance(url, six.string_types): if isinstance(url, str):
# We're just a simple URL string... # We're just a simple URL string...
schema = GET_SCHEMA_RE.match(url) schema = GET_SCHEMA_RE.match(url)
if schema is None: if schema is None:
@ -818,10 +816,7 @@ class ConfigBase(URLBase):
# can at least tell the end user what entries were ignored # can at least tell the end user what entries were ignored
# due to errors # due to errors
if six.PY2: it = iter(url.items())
it = url.iteritems()
else: # six.PY3
it = iter(url.items())
# Track the URL to-load # Track the URL to-load
_url = None _url = None
@ -871,17 +866,14 @@ class ConfigBase(URLBase):
# We are a url string with additional unescaped options # We are a url string with additional unescaped options
if isinstance(entries, dict): if isinstance(entries, dict):
if six.PY2: _url, tokens = next(iter(url.items()))
_url, tokens = next(url.iteritems())
else: # six.PY3
_url, tokens = next(iter(url.items()))
# Tags you just can't over-ride # Tags you just can't over-ride
if 'schema' in entries: if 'schema' in entries:
del entries['schema'] del entries['schema']
# support our special tokens (if they're present) # support our special tokens (if they're present)
if schema in plugins.SCHEMA_MAP: if schema in common.NOTIFY_SCHEMA_MAP:
entries = ConfigBase._special_token_handler( entries = ConfigBase._special_token_handler(
schema, entries) schema, entries)
@ -893,7 +885,7 @@ class ConfigBase(URLBase):
elif isinstance(tokens, dict): elif isinstance(tokens, dict):
# support our special tokens (if they're present) # support our special tokens (if they're present)
if schema in plugins.SCHEMA_MAP: if schema in common.NOTIFY_SCHEMA_MAP:
tokens = ConfigBase._special_token_handler( tokens = ConfigBase._special_token_handler(
schema, tokens) schema, tokens)
@ -927,6 +919,14 @@ class ConfigBase(URLBase):
# Grab our first item # Grab our first item
_results = results.pop(0) _results = results.pop(0)
if _results['schema'] not in common.NOTIFY_SCHEMA_MAP:
# the arguments are invalid or can not be used.
ConfigBase.logger.warning(
'An invalid Apprise schema ({}) in YAML configuration '
'entry #{}, item #{}'
.format(_results['schema'], no + 1, entry))
continue
# tag is a special keyword that is managed by Apprise object. # tag is a special keyword that is managed by Apprise object.
# The below ensures our tags are set correctly # The below ensures our tags are set correctly
if 'tag' in _results: if 'tag' in _results:
@ -958,10 +958,12 @@ class ConfigBase(URLBase):
# Prepare our Asset Object # Prepare our Asset Object
_results['asset'] = asset _results['asset'] = asset
# Now we generate our plugin
try: try:
# Attempt to create an instance of our plugin using the # Attempt to create an instance of our plugin using the
# parsed URL information # parsed URL information
plugin = plugins.SCHEMA_MAP[_results['schema']](**_results) plugin = common.\
NOTIFY_SCHEMA_MAP[_results['schema']](**_results)
# Create log entry of loaded URL # Create log entry of loaded URL
ConfigBase.logger.debug( ConfigBase.logger.debug(
@ -1014,7 +1016,7 @@ class ConfigBase(URLBase):
# Create a copy of our dictionary # Create a copy of our dictionary
tokens = tokens.copy() tokens = tokens.copy()
for kw, meta in plugins.SCHEMA_MAP[schema]\ for kw, meta in common.NOTIFY_SCHEMA_MAP[schema]\
.template_kwargs.items(): .template_kwargs.items():
# Determine our prefix: # Determine our prefix:
@ -1059,7 +1061,7 @@ class ConfigBase(URLBase):
# This function here allows these mappings to take place within the # This function here allows these mappings to take place within the
# YAML file as independant arguments. # YAML file as independant arguments.
class_templates = \ class_templates = \
plugins.details(plugins.SCHEMA_MAP[schema]) plugins.details(common.NOTIFY_SCHEMA_MAP[schema])
for key in list(tokens.keys()): for key in list(tokens.keys()):
@ -1088,7 +1090,7 @@ class ConfigBase(URLBase):
# Detect if we're dealign with a list or not # Detect if we're dealign with a list or not
is_list = re.search( is_list = re.search(
r'^(list|choice):.*', r'^list:.*',
meta.get('type'), meta.get('type'),
re.IGNORECASE) re.IGNORECASE)
@ -1105,7 +1107,7 @@ class ConfigBase(URLBase):
r'^(choice:)?string', r'^(choice:)?string',
meta.get('type'), meta.get('type'),
re.IGNORECASE) \ re.IGNORECASE) \
and not isinstance(value, six.string_types): and not isinstance(value, str):
# Ensure our format is as expected # Ensure our format is as expected
value = str(value) value = str(value)
@ -1158,19 +1160,8 @@ class ConfigBase(URLBase):
def __bool__(self): def __bool__(self):
""" """
Allows the Apprise object to be wrapped in an Python 3.x based 'if Allows the Apprise object to be wrapped in an 'if statement'.
statement'. True is returned if our content was downloaded correctly. 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): if not isinstance(self._cached_servers, list):
# Generate ourselves a list of content we can pull from # Generate ourselves a list of content we can pull from

View file

@ -24,7 +24,6 @@
# THE SOFTWARE. # THE SOFTWARE.
import re import re
import io
import os import os
from .ConfigBase import ConfigBase from .ConfigBase import ConfigBase
from ..common import ConfigFormat from ..common import ConfigFormat
@ -119,9 +118,7 @@ class ConfigFile(ConfigBase):
self.throttle() self.throttle()
try: try:
# Python 3 just supports open(), however to remain compatible with with open(self.path, "rt", encoding=self.encoding) as f:
# Python 2, we use the io module
with io.open(self.path, "rt", encoding=self.encoding) as f:
# Store our content for parsing # Store our content for parsing
response = f.read() response = f.read()

View file

@ -24,7 +24,6 @@
# THE SOFTWARE. # THE SOFTWARE.
import re import re
import six
import requests import requests
from .ConfigBase import ConfigBase from .ConfigBase import ConfigBase
from ..common import ConfigFormat from ..common import ConfigFormat
@ -81,7 +80,7 @@ class ConfigHTTP(ConfigBase):
self.schema = 'https' if self.secure else 'http' self.schema = 'https' if self.secure else 'http'
self.fullpath = kwargs.get('fullpath') self.fullpath = kwargs.get('fullpath')
if not isinstance(self.fullpath, six.string_types): if not isinstance(self.fullpath, str):
self.fullpath = '/' self.fullpath = '/'
self.headers = {} self.headers = {}

View file

@ -24,14 +24,11 @@
# THE SOFTWARE. # THE SOFTWARE.
import re import re
import six
from os import listdir from os import listdir
from os.path import dirname from os.path import dirname
from os.path import abspath from os.path import abspath
from ..logger import logger from ..logger import logger
from ..common import CONFIG_SCHEMA_MAP
# Maintains a mapping of all of the configuration services
SCHEMA_MAP = {}
__all__ = [] __all__ = []
@ -89,40 +86,20 @@ def __load_matrix(path=abspath(dirname(__file__)), name='apprise.config'):
globals()[plugin_name] = plugin globals()[plugin_name] = plugin
fn = getattr(plugin, 'schemas', None) fn = getattr(plugin, 'schemas', None)
try: schemas = set([]) if not callable(fn) else fn(plugin)
schemas = set([]) if not callable(fn) else fn(plugin)
except TypeError:
# Python v2.x support where functions associated with classes
# were considered bound to them and could not be called prior
# to the classes initialization. This code can be dropped
# once Python v2.x support is dropped. The below code introduces
# replication as it already exists and is tested in
# URLBase.schemas()
schemas = set([])
for key in ('protocol', 'secure_protocol'):
schema = getattr(plugin, key, None)
if isinstance(schema, six.string_types):
schemas.add(schema)
elif isinstance(schema, (set, list, tuple)):
# Support iterables list types
for s in schema:
if isinstance(s, six.string_types):
schemas.add(s)
# map our schema to our plugin # map our schema to our plugin
for schema in schemas: for schema in schemas:
if schema in SCHEMA_MAP: if schema in CONFIG_SCHEMA_MAP:
logger.error( logger.error(
"Config schema ({}) mismatch detected - {} to {}" "Config schema ({}) mismatch detected - {} to {}"
.format(schema, SCHEMA_MAP[schema], plugin)) .format(schema, CONFIG_SCHEMA_MAP[schema], plugin))
continue continue
# Assign plugin # Assign plugin
SCHEMA_MAP[schema] = plugin CONFIG_SCHEMA_MAP[schema] = plugin
return SCHEMA_MAP return CONFIG_SCHEMA_MAP
# Dynamically build our schema base # Dynamically build our schema base

View file

@ -23,18 +23,12 @@
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
# THE SOFTWARE. # THE SOFTWARE.
import re import re
import six
from markdown import markdown from markdown import markdown
from .common import NotifyFormat from .common import NotifyFormat
from .URLBase import URLBase from .URLBase import URLBase
if six.PY2: from html.parser import HTMLParser
from HTMLParser import HTMLParser
else:
from html.parser import HTMLParser
def convert_between(from_format, to_format, content): def convert_between(from_format, to_format, content):
@ -70,7 +64,8 @@ def text_to_html(content):
Converts specified content from plain text to HTML. Converts specified content from plain text to HTML.
""" """
return URLBase.escape_html(content) # First eliminate any carriage returns
return URLBase.escape_html(content, convert_new_lines=True)
def html_to_text(content): def html_to_text(content):
@ -79,10 +74,6 @@ def html_to_text(content):
""" """
parser = HTMLConverter() parser = HTMLConverter()
if six.PY2:
# Python 2.7 requires an additional parsing to un-escape characters
content = parser.unescape(content)
parser.feed(content) parser.feed(content)
parser.close() parser.close()
return parser.converted return parser.converted
@ -96,7 +87,9 @@ class HTMLConverter(HTMLParser, object):
'div', 'td', 'th', 'code', 'pre', 'label', 'li',) 'div', 'td', 'th', 'code', 'pre', 'label', 'li',)
# the folowing tags ignore any internal text # the folowing tags ignore any internal text
IGNORE_TAGS = ('style', 'link', 'meta', 'title', 'html', 'head', 'script') IGNORE_TAGS = (
'form', 'input', 'textarea', 'select', 'ul', 'ol', 'style', 'link',
'meta', 'title', 'html', 'head', 'script')
# Condense Whitespace # Condense Whitespace
WS_TRIM = re.compile(r'[\s]+', re.DOTALL | re.MULTILINE) WS_TRIM = re.compile(r'[\s]+', re.DOTALL | re.MULTILINE)
@ -122,14 +115,6 @@ class HTMLConverter(HTMLParser, object):
string = ''.join(self._finalize(self._result)) string = ''.join(self._finalize(self._result))
self.converted = string.strip() self.converted = string.strip()
if six.PY2:
# See https://stackoverflow.com/questions/10993612/\
# how-to-remove-xa0-from-string-in-python
#
# This is required since the unescape() nbsp; with \xa0 when
# using Python 2.7
self.converted = self.converted.replace(u'\xa0', u' ')
def _finalize(self, result): def _finalize(self, result):
""" """
Combines and strips consecutive strings, then converts consecutive Combines and strips consecutive strings, then converts consecutive

View file

@ -0,0 +1,226 @@
# -*- coding: utf-8 -*-
#
# Copyright (C) 2022 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.
from ..plugins.NotifyBase import NotifyBase
from ..utils import URL_DETAILS_RE
from ..utils import parse_url
from ..utils import url_assembly
from ..utils import dict_full_update
from .. import common
from ..logger import logger
import inspect
class CustomNotifyPlugin(NotifyBase):
"""
Apprise Custom Plugin Hook
This gets initialized based on @notify decorator definitions
"""
# Our Custom notification
service_url = 'https://github.com/caronc/apprise/wiki/Custom_Notification'
# Over-ride our category since this inheritance of the NotifyBase class
# should be treated differently.
category = 'custom'
# Define object templates
templates = (
'{schema}://',
)
@staticmethod
def parse_url(url):
"""
Parses the URL and returns arguments retrieved
"""
return parse_url(url, verify_host=False, simple=True)
def url(self, privacy=False, *args, **kwargs):
"""
General URL assembly
"""
return '{schema}://'.format(schema=self.secure_protocol)
@staticmethod
def instantiate_plugin(url, send_func, name=None):
"""
The function used to add a new notification plugin based on the schema
parsed from the provided URL into our supported matrix structure.
"""
if not isinstance(url, str):
msg = 'An invalid custom notify url/schema ({}) provided in ' \
'function {}.'.format(url, send_func.__name__)
logger.warning(msg)
return None
# Validate that our schema is okay
re_match = URL_DETAILS_RE.match(url)
if not re_match:
msg = 'An invalid custom notify url/schema ({}) provided in ' \
'function {}.'.format(url, send_func.__name__)
logger.warning(msg)
return None
# Acquire our plugin name
plugin_name = re_match.group('schema').lower()
if not re_match.group('base'):
url = '{}://'.format(plugin_name)
# Keep a default set of arguments to apply to all called references
base_args = parse_url(
url, default_schema=plugin_name, verify_host=False, simple=True)
if plugin_name in common.NOTIFY_SCHEMA_MAP:
# we're already handling this object
msg = 'The schema ({}) is already defined and could not be ' \
'loaded from custom notify function {}.' \
.format(url, send_func.__name__)
logger.warning(msg)
return None
# We define our own custom wrapper class so that we can initialize
# some key default configuration values allowing calls to our
# `Apprise.details()` to correctly differentiate one custom plugin
# that was loaded from another
class CustomNotifyPluginWrapper(CustomNotifyPlugin):
# Our Service Name
service_name = name if isinstance(name, str) \
and name else 'Custom - {}'.format(plugin_name)
# Store our matched schema
secure_protocol = plugin_name
requirements = {
# Define our required packaging in order to work
'details': "Source: {}".format(inspect.getfile(send_func))
}
# Assign our send() function
__send = staticmethod(send_func)
# Update our default arguments
_base_args = base_args
def __init__(self, **kwargs):
"""
Our initialization
"""
# init parent
super(CustomNotifyPluginWrapper, self).__init__(**kwargs)
self._default_args = {}
# Apply our updates based on what was parsed
dict_full_update(self._default_args, self._base_args)
dict_full_update(self._default_args, kwargs)
# Update our arguments (applying them to what we originally)
# initialized as
self._default_args['url'] = url_assembly(**self._default_args)
def send(self, body, title='', notify_type=common.NotifyType.INFO,
*args, **kwargs):
"""
Our send() call which triggers our hook
"""
response = False
try:
# Enforce a boolean response
result = self.__send(
body, title, notify_type, *args,
meta=self._default_args, **kwargs)
if result is None:
# The wrapper did not define a return (or returned
# None)
# this is treated as a successful return as it is
# assumed the developer did not care about the result
# of the call.
response = True
else:
# Perform boolean check (allowing obects to also be
# returned and check against the __bool__ call
response = True if result else False
except Exception as e:
# Unhandled Exception
self.logger.warning(
'An exception occured sending a %s notification.',
common.
NOTIFY_SCHEMA_MAP[self.secure_protocol].service_name)
self.logger.debug(
'%s Exception: %s',
common.NOTIFY_SCHEMA_MAP[self.secure_protocol], str(e))
return False
if response:
self.logger.info(
'Sent %s notification.',
common.
NOTIFY_SCHEMA_MAP[self.secure_protocol].service_name)
else:
self.logger.warning(
'Failed to send %s notification.',
common.
NOTIFY_SCHEMA_MAP[self.secure_protocol].service_name)
return response
# Store our plugin into our core map file
common.NOTIFY_SCHEMA_MAP[plugin_name] = CustomNotifyPluginWrapper
# Update our custom plugin map
module_pyname = str(send_func.__module__)
if module_pyname not in common.NOTIFY_CUSTOM_MODULE_MAP:
# Support non-dynamic includes as well...
common.NOTIFY_CUSTOM_MODULE_MAP[module_pyname] = {
'path': inspect.getfile(send_func),
# Initialize our template
'notify': {},
}
common.\
NOTIFY_CUSTOM_MODULE_MAP[module_pyname]['notify'][plugin_name] = {
# Our Serivice Description (for API and CLI --details view)
'name': CustomNotifyPluginWrapper.service_name,
# The name of the send function the @notify decorator wrapped
'fn_name': send_func.__name__,
# The URL that was provided in the @notify decorator call
# associated with the 'on='
'url': url,
# The Initialized Plugin that was generated based on the above
# parameters
'plugin': CustomNotifyPluginWrapper}
# return our plugin
return common.NOTIFY_SCHEMA_MAP[plugin_name]

View file

@ -0,0 +1,30 @@
# -*- coding: utf-8 -*-
#
# Copyright (C) 2022 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.
from .notify import notify
__all__ = [
'notify'
]

View file

@ -0,0 +1,123 @@
# -*- coding: utf-8 -*-
#
# Copyright (C) 2022 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.
from .CustomNotifyPlugin import CustomNotifyPlugin
def notify(on, name=None):
"""
@notify decorator allows you to map functions you've defined to be loaded
as a regular notify by Apprise. You must identify a protocol that
users will trigger your call by.
@notify(on="foobar")
def your_declaration(body, title, notify_type, meta, *args, **kwargs):
...
You can optionally provide the name to associate with the plugin which
is what calling functions via the API will receive.
@notify(on="foobar", name="My Foobar Process")
def your_action(body, title, notify_type, meta, *args, **kwargs):
...
The meta variable is actually the processed URL contents found in
configuration files that landed you in this function you wrote in
the first place. It's very easily tokenized already for you so
that you can bend the notification logic to your hearts content.
@notify(on="foobar", name="My Foobar Process")
def your_action(body, title, notify_type, body_format, meta, attach,
*args, **kwargs):
...
Arguments break down as follows:
body: The message body associated with the notification
title: The message title associated with the notification
notify_type: The message type (info, success, warning, and failure)
body_format: The format of the incoming notification body. This is
either text, html, or markdown.
meta: Combines the URL arguments specified on the `on` call
with the ones loaded from a users configuration. This
is a dictionary that presents itself like this:
{
'schema': 'http',
'url': 'http://hostname',
'host': 'hostname',
'user': 'john',
'password': 'doe',
'port': 80,
'path': '/',
'fullpath': '/test.php',
'query': 'test.php',
'qsd': {'key': 'value', 'key2': 'value2'},
'asset': <AppriseAsset>,
'tag': set(),
}
Meta entries are ONLY present if found. A simple URL
such as foobar:// would only produce the following:
{
'schema': 'foobar',
'url': 'foobar://',
'asset': <AppriseAsset>,
'tag': set(),
}
attach: An array AppriseAttachment objects (if any were provided)
body_format: Defaults to the expected format output; By default this
will be TEXT unless over-ridden in the Apprise URL
If you don't intend on using all of the parameters, your @notify() call
# can be greatly simplified to just:
@notify(on="foobar", name="My Foobar Process")
def your_action(body, title, *args, **kwargs)
Always end your wrappers declaration with *args and **kwargs to be future
proof with newer versions of Apprise.
Your wrapper should return True if processed the send() function as you
expected and return False if not. If nothing is returned, then this is
treated as as success (True).
"""
def wrapper(func):
"""
Instantiate our custom (notification) plugin
"""
# Generate
CustomNotifyPlugin.instantiate_plugin(
url=on, send_func=func, name=name)
return func
return wrapper

View file

@ -66,7 +66,7 @@ logging.Logger.deprecate = deprecate
logger = logging.getLogger(LOGGER_NAME) logger = logging.getLogger(LOGGER_NAME)
class LogCapture(object): class LogCapture:
""" """
A class used to allow one to instantiate loggers that write to A class used to allow one to instantiate loggers that write to
memory for temporary purposes. e.g.: memory for temporary purposes. e.g.:

View file

@ -24,7 +24,6 @@
# THE SOFTWARE. # THE SOFTWARE.
import re import re
import six
import requests import requests
from json import dumps from json import dumps
@ -137,7 +136,7 @@ class NotifyAppriseAPI(NotifyBase):
super(NotifyAppriseAPI, self).__init__(**kwargs) super(NotifyAppriseAPI, self).__init__(**kwargs)
self.fullpath = kwargs.get('fullpath') self.fullpath = kwargs.get('fullpath')
if not isinstance(self.fullpath, six.string_types): if not isinstance(self.fullpath, str):
self.fullpath = '/' self.fullpath = '/'
self.token = validate_regex( self.token = validate_regex(
@ -339,18 +338,10 @@ class NotifyAppriseAPI(NotifyBase):
return results return results
# Add our headers that the user can potentially over-ride if they wish # Add our headers that the user can potentially over-ride if they wish
# to to our returned result set # to to our returned result set and tidy entries by unquoting them
results['headers'] = results['qsd+']
if results['qsd-']:
results['headers'].update(results['qsd-'])
NotifyBase.logger.deprecate(
"minus (-) based Apprise API header tokens are being "
" removed; use the plus (+) symbol instead.")
# Tidy our header entries by unquoting them
results['headers'] = \ results['headers'] = \
{NotifyAppriseAPI.unquote(x): NotifyAppriseAPI.unquote(y) {NotifyAppriseAPI.unquote(x): NotifyAppriseAPI.unquote(y)
for x, y in results['headers'].items()} for x, y in results['qsd+'].items()}
# Support the passing of tags in the URL # Support the passing of tags in the URL
if 'tags' in results['qsd'] and len(results['qsd']['tags']): if 'tags' in results['qsd'] and len(results['qsd']['tags']):

View file

@ -0,0 +1,506 @@
# -*- coding: utf-8 -*-
#
# Copyright (C) 2022 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.
#
# API: https://github.com/Finb/bark-server/blob/master/docs/API_V2.md#python
#
import requests
import json
from .NotifyBase import NotifyBase
from ..URLBase import PrivacyMode
from ..common import NotifyImageSize
from ..common import NotifyType
from ..utils import parse_list
from ..utils import parse_bool
from ..AppriseLocale import gettext_lazy as _
# Sounds generated off of: https://github.com/Finb/Bark/tree/master/Sounds
BARK_SOUNDS = (
"alarm.caf",
"anticipate.caf",
"bell.caf",
"birdsong.caf",
"bloom.caf",
"calypso.caf",
"chime.caf",
"choo.caf",
"descent.caf",
"electronic.caf",
"fanfare.caf",
"glass.caf",
"gotosleep.caf",
"healthnotification.caf",
"horn.caf",
"ladder.caf",
"mailsent.caf",
"minuet.caf",
"multiwayinvitation.caf",
"newmail.caf",
"newsflash.caf",
"noir.caf",
"paymentsuccess.caf",
"shake.caf",
"sherwoodforest.caf",
"silence.caf",
"spell.caf",
"suspense.caf",
"telegraph.caf",
"tiptoes.caf",
"typewriters.caf",
"update.caf",
)
# Supported Level Entries
class NotifyBarkLevel:
"""
Defines the Bark Level options
"""
ACTIVE = 'active'
TIME_SENSITIVE = 'timeSensitive'
PASSIVE = 'passive'
BARK_LEVELS = (
NotifyBarkLevel.ACTIVE,
NotifyBarkLevel.TIME_SENSITIVE,
NotifyBarkLevel.PASSIVE,
)
class NotifyBark(NotifyBase):
"""
A wrapper for Notify Bark Notifications
"""
# The default descriptive name associated with the Notification
service_name = 'Bark'
# The services URL
service_url = 'https://github.com/Finb/Bark'
# The default protocol
protocol = 'bark'
# The default secure protocol
secure_protocol = 'barks'
# A URL that takes you to the setup/help of the specific protocol
setup_url = 'https://github.com/caronc/apprise/wiki/Notify_bark'
# Allows the user to specify the NotifyImageSize object; this is supported
# through the webhook
image_size = NotifyImageSize.XY_128
# Define object templates
templates = (
'{schema}://{host}:{port}/{targets}',
'{schema}://{user}:{password}@{host}/{targets}',
'{schema}://{user}:{password}@{host}:{port}/{targets}',
'{schema}://{user}:{password}@{host}/{targets}',
)
# Define our template arguments
template_tokens = dict(NotifyBase.template_tokens, **{
'host': {
'name': _('Hostname'),
'type': 'string',
'required': True,
},
'port': {
'name': _('Port'),
'type': 'int',
'min': 1,
'max': 65535,
},
'user': {
'name': _('Username'),
'type': 'string',
},
'password': {
'name': _('Password'),
'type': 'string',
'private': True,
},
'target_device': {
'name': _('Target Device'),
'type': 'string',
'map_to': 'targets',
},
'targets': {
'name': _('Targets'),
'type': 'list:string',
},
})
# Define our template arguments
template_args = dict(NotifyBase.template_args, **{
'to': {
'alias_of': 'targets',
},
'sound': {
'name': _('Sound'),
'type': 'choice:string',
'values': BARK_SOUNDS,
},
'level': {
'name': _('Level'),
'type': 'choice:string',
'values': BARK_LEVELS,
},
'click': {
'name': _('Click'),
'type': 'string',
},
'badge': {
'name': _('Badge'),
'type': 'int',
'min': 0,
},
'category': {
'name': _('Category'),
'type': 'string',
},
'group': {
'name': _('Group'),
'type': 'string',
},
'image': {
'name': _('Include Image'),
'type': 'bool',
'default': True,
'map_to': 'include_image',
},
})
def __init__(self, targets=None, include_image=True, sound=None,
category=None, group=None, level=None, click=None,
badge=None, **kwargs):
"""
Initialize Notify Bark Object
"""
super(NotifyBark, self).__init__(**kwargs)
# Prepare our URL
self.notify_url = '%s://%s%s/push' % (
'https' if self.secure else 'http',
self.host,
':{}'.format(self.port)
if (self.port and isinstance(self.port, int)) else '',
)
# Assign our category
self.category = \
category if isinstance(category, str) else None
# Assign our group
self.group = group if isinstance(group, str) else None
# Initialize device list
self.targets = parse_list(targets)
# Place an image inline with the message body
self.include_image = include_image
# A clickthrough option for notifications
self.click = click
# Badge
try:
# Acquire our badge count if we can:
# - We accept both the integer form as well as a string
# representation
self.badge = int(badge)
if self.badge < 0:
raise ValueError()
except TypeError:
# NoneType means use Default; this is an okay exception
self.badge = None
except ValueError:
self.badge = None
self.logger.warning(
'The specified Bark badge ({}) is not valid ', badge)
# Sound (easy-lookup)
self.sound = None if not sound else next(
(f for f in BARK_SOUNDS if f.startswith(sound.lower())), None)
if sound and not self.sound:
self.logger.warning(
'The specified Bark sound ({}) was not found ', sound)
# Level
self.level = None if not level else next(
(f for f in BARK_LEVELS if f[0] == level[0]), None)
if level and not self.level:
self.logger.warning(
'The specified Bark level ({}) is not valid ', level)
return
def send(self, body, title='', notify_type=NotifyType.INFO, **kwargs):
"""
Perform Bark Notification
"""
# error tracking (used for function return)
has_error = False
if not len(self.targets):
# We have nothing to notify; we're done
self.logger.warning('There are no Bark devices to notify')
return False
# Prepare our headers
headers = {
'User-Agent': self.app_id,
'Content-Type': 'application/json; charset=utf-8',
}
# Prepare our payload (sample below)
# {
# "body": "Test Bark Server",
# "device_key": "nysrshcqielvoxsa",
# "title": "bleem",
# "category": "category",
# "sound": "minuet.caf",
# "badge": 1,
# "icon": "https://day.app/assets/images/avatar.jpg",
# "group": "test",
# "url": "https://mritd.com"
# }
payload = {
'title': title if title else self.app_desc,
'body': body,
}
# Acquire our image url if configured to do so
image_url = None if not self.include_image else \
self.image_url(notify_type)
if image_url:
payload['icon'] = image_url
if self.sound:
payload['sound'] = self.sound
if self.click:
payload['url'] = self.click
if self.badge:
payload['badge'] = self.badge
if self.level:
payload['level'] = self.level
if self.category:
payload['category'] = self.category
if self.group:
payload['group'] = self.group
auth = None
if self.user:
auth = (self.user, self.password)
# Create a copy of the targets
targets = list(self.targets)
while len(targets) > 0:
# Retrieve our device key
target = targets.pop()
payload['device_key'] = target
self.logger.debug('Bark POST URL: %s (cert_verify=%r)' % (
self.notify_url, self.verify_certificate,
))
self.logger.debug('Bark 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=json.dumps(payload),
headers=headers,
auth=auth,
verify=self.verify_certificate,
timeout=self.request_timeout,
)
if r.status_code != requests.codes.ok:
# We had a problem
status_str = \
NotifyBark.http_response_code_lookup(
r.status_code)
self.logger.warning(
'Failed to send Bark notification to {}: '
'{}{}error={}.'.format(
target,
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 Bark notification to {}.'.format(target))
except requests.RequestException as e:
self.logger.warning(
'A Connection error occurred sending Bark '
'notification to {}.'.format(target))
self.logger.debug('Socket Exception: %s' % str(e))
# Mark our failure
has_error = True
continue
return not has_error
def url(self, privacy=False, *args, **kwargs):
"""
Returns the URL built dynamically based on specified arguments.
"""
# Define any URL parameters
params = {
'image': 'yes' if self.include_image else 'no',
}
if self.sound:
params['sound'] = self.sound
if self.click:
params['click'] = self.click
if self.badge:
params['badge'] = str(self.badge)
if self.level:
params['level'] = self.level
if self.category:
params['category'] = self.category
if self.group:
params['group'] = self.group
# Extend our parameters
params.update(self.url_parameters(privacy=privacy, *args, **kwargs))
# Determine Authentication
auth = ''
if self.user and self.password:
auth = '{user}:{password}@'.format(
user=NotifyBark.quote(self.user, safe=''),
password=self.pprint(
self.password, privacy, mode=PrivacyMode.Secret, safe=''),
)
elif self.user:
auth = '{user}@'.format(
user=NotifyBark.quote(self.user, safe=''),
)
default_port = 443 if self.secure else 80
return '{schema}://{auth}{hostname}{port}/{targets}?{params}'.format(
schema=self.secure_protocol if self.secure else self.protocol,
auth=auth,
# never encode hostname since we're expecting it to be a valid one
hostname=self.host,
port='' if self.port is None or self.port == default_port
else ':{}'.format(self.port),
targets='/'.join(
[NotifyBark.quote('{}'.format(x)) for x in self.targets]),
params=NotifyBark.urlencode(params),
)
@staticmethod
def parse_url(url):
"""
Parses the URL and returns enough arguments that can allow
us to re-instantiate this object.
"""
results = NotifyBase.parse_url(url)
if not results:
# We're done early as we couldn't load the results
return results
# Apply our targets
results['targets'] = NotifyBark.split_path(results['fullpath'])
# Category
if 'category' in results['qsd'] and results['qsd']['category']:
results['category'] = NotifyBark.unquote(
results['qsd']['category'].strip())
# Group
if 'group' in results['qsd'] and results['qsd']['group']:
results['group'] = NotifyBark.unquote(
results['qsd']['group'].strip())
# Badge
if 'badge' in results['qsd'] and results['qsd']['badge']:
results['badge'] = NotifyBark.unquote(
results['qsd']['badge'].strip())
# Level
if 'level' in results['qsd'] and results['qsd']['level']:
results['level'] = NotifyBark.unquote(
results['qsd']['level'].strip())
# Click (URL)
if 'click' in results['qsd'] and results['qsd']['click']:
results['click'] = NotifyBark.unquote(
results['qsd']['click'].strip())
# Sound
if 'sound' in results['qsd'] and results['qsd']['sound']:
results['sound'] = NotifyBark.unquote(
results['qsd']['sound'].strip())
# The 'to' makes it easier to use yaml configuration
if 'to' in results['qsd'] and len(results['qsd']['to']):
results['targets'] += \
NotifyBark.parse_list(results['qsd']['to'])
# use image= for consistency with the other plugins
results['include_image'] = \
parse_bool(results['qsd'].get('image', True))
return results

View file

@ -24,7 +24,6 @@
# THE SOFTWARE. # THE SOFTWARE.
import re import re
import six
from ..URLBase import URLBase from ..URLBase import URLBase
from ..common import NotifyType from ..common import NotifyType
@ -37,14 +36,9 @@ from ..AppriseLocale import gettext_lazy as _
from ..AppriseAttachment import AppriseAttachment from ..AppriseAttachment import AppriseAttachment
if six.PY3: # Wrap our base with the asyncio wrapper
# Wrap our base with the asyncio wrapper from ..py3compat.asyncio import AsyncNotifyBase
from ..py3compat.asyncio import AsyncNotifyBase BASE_OBJECT = AsyncNotifyBase
BASE_OBJECT = AsyncNotifyBase
else:
# Python v2.7 (backwards compatibility)
BASE_OBJECT = URLBase
class NotifyBase(BASE_OBJECT): class NotifyBase(BASE_OBJECT):
@ -59,6 +53,15 @@ class NotifyBase(BASE_OBJECT):
# enabled. # enabled.
enabled = True enabled = True
# The category allows for parent inheritance of this object to alter
# this when it's function/use is intended to behave differently. The
# following category types exist:
#
# native: Is a native plugin written/stored in `apprise/plugins/Notify*`
# custom: Is a custom plugin written/stored in a users plugin directory
# that they loaded at execution time.
category = 'native'
# Some plugins may require additional packages above what is provided # Some plugins may require additional packages above what is provided
# already by Apprise. # already by Apprise.
# #

View file

@ -24,7 +24,6 @@
# THE SOFTWARE. # THE SOFTWARE.
import re import re
import six
import requests import requests
import hmac import hmac
from json import dumps from json import dumps
@ -181,7 +180,7 @@ class NotifyBoxcar(NotifyBase):
self.tags.append(DEFAULT_TAG) self.tags.append(DEFAULT_TAG)
targets = [] targets = []
elif isinstance(targets, six.string_types): elif isinstance(targets, str):
targets = [x for x in filter(bool, TAGS_LIST_DELIM.split( targets = [x for x in filter(bool, TAGS_LIST_DELIM.split(
targets, targets,
))] ))]
@ -357,13 +356,8 @@ class NotifyBoxcar(NotifyBase):
# by default # by default
entries = NotifyBoxcar.split_path(results['fullpath']) entries = NotifyBoxcar.split_path(results['fullpath'])
try: # Now fetch the remaining tokens
# Now fetch the remaining tokens results['secret'] = entries.pop(0) if entries else None
results['secret'] = entries.pop(0)
except IndexError:
# secret wasn't specified
results['secret'] = None
# Our recipients make up the remaining entries of our array # Our recipients make up the remaining entries of our array
results['targets'] = entries results['targets'] = entries

View file

@ -0,0 +1,457 @@
# -*- coding: utf-8 -*-
#
# Copyright (C) 2022 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.
# To use this service you will need a BulkSMS account
# You will need credits (new accounts start with a few)
# https://www.bulksms.com/account/
#
# API is documented here:
# - https://www.bulksms.com/developer/json/v1/#tag/Message
import re
import six
import requests
import json
from itertools import chain
from .NotifyBase import NotifyBase
from ..URLBase import PrivacyMode
from ..common import NotifyType
from ..utils import is_phone_no
from ..utils import parse_phone_no
from ..utils import parse_bool
from ..AppriseLocale import gettext_lazy as _
IS_GROUP_RE = re.compile(
r'^(@?(?P<group>[A-Z0-9_-]+))$',
re.IGNORECASE,
)
class BulkSMSRoutingGroup(object):
"""
The different categories of routing
"""
ECONOMY = "ECONOMY"
STANDARD = "STANDARD"
PREMIUM = "PREMIUM"
# Used for verification purposes
BULKSMS_ROUTING_GROUPS = (
BulkSMSRoutingGroup.ECONOMY,
BulkSMSRoutingGroup.STANDARD,
BulkSMSRoutingGroup.PREMIUM,
)
class BulkSMSEncoding(object):
"""
The different categories of routing
"""
TEXT = "TEXT"
UNICODE = "UNICODE"
BINARY = "BINARY"
class NotifyBulkSMS(NotifyBase):
"""
A wrapper for BulkSMS Notifications
"""
# The default descriptive name associated with the Notification
service_name = 'BulkSMS'
# The services URL
service_url = 'https://bulksms.com/'
# All notification requests are secure
secure_protocol = 'bulksms'
# A URL that takes you to the setup/help of the specific protocol
setup_url = 'https://github.com/caronc/apprise/wiki/Notify_bulksms'
# BulkSMS uses the http protocol with JSON requests
notify_url = 'https://api.bulksms.com/v1/messages'
# The maximum length of the body
body_maxlen = 160
# The maximum amount of texts that can go out in one batch
default_batch_size = 4000
# 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
# Define object templates
templates = (
'{schema}://{user}:{password}@{targets}',
)
# Define our template tokens
template_tokens = dict(NotifyBase.template_tokens, **{
'user': {
'name': _('User Name'),
'type': 'string',
},
'password': {
'name': _('Password'),
'type': 'string',
'private': True,
},
'target_phone': {
'name': _('Target Phone No'),
'type': 'string',
'prefix': '+',
'regex': (r'^[0-9\s)(+-]+$', 'i'),
'map_to': 'targets',
},
'target_group': {
'name': _('Target Group'),
'type': 'string',
'prefix': '+',
'regex': (r'^[A-Z0-9 _-]+$', 'i'),
'map_to': 'targets',
},
'targets': {
'name': _('Targets'),
'type': 'list:string',
},
})
# Define our template arguments
template_args = dict(NotifyBase.template_args, **{
'to': {
'alias_of': 'targets',
},
'from': {
'name': _('From Phone No'),
'type': 'string',
'regex': (r'^\+?[0-9\s)(+-]+$', 'i'),
'map_to': 'source',
},
'route': {
'name': _('Route Group'),
'type': 'choice:string',
'values': BULKSMS_ROUTING_GROUPS,
'default': BulkSMSRoutingGroup.STANDARD,
},
'unicode': {
# Unicode characters
'name': _('Unicode Characters'),
'type': 'bool',
'default': True,
},
'batch': {
'name': _('Batch Mode'),
'type': 'bool',
'default': False,
},
})
def __init__(self, source=None, targets=None, unicode=None, batch=None,
route=None, **kwargs):
"""
Initialize BulkSMS Object
"""
super(NotifyBulkSMS, self).__init__(**kwargs)
self.source = None
if source:
result = is_phone_no(source)
if not result:
msg = 'The Account (From) Phone # specified ' \
'({}) is invalid.'.format(source)
self.logger.warning(msg)
raise TypeError(msg)
# Tidy source
self.source = '+{}'.format(result['full'])
# Setup our route
self.route = self.template_args['route']['default'] \
if not isinstance(route, six.string_types) else route.upper()
if self.route not in BULKSMS_ROUTING_GROUPS:
msg = 'The route specified ({}) is invalid.'.format(route)
self.logger.warning(msg)
raise TypeError(msg)
# Define whether or not we should set the unicode flag
self.unicode = self.template_args['unicode']['default'] \
if unicode is None else bool(unicode)
# Define whether or not we should operate in a batch mode
self.batch = self.template_args['batch']['default'] \
if batch is None else bool(batch)
# Parse our targets
self.targets = list()
self.groups = list()
for target in parse_phone_no(targets):
# Parse each phone number we found
result = is_phone_no(target)
if result:
self.targets.append('+{}'.format(result['full']))
continue
group_re = IS_GROUP_RE.match(target)
if group_re and not target.isdigit():
# If the target specified is all digits, it MUST have a @
# in front of it to eliminate any ambiguity
self.groups.append(group_re.group('group'))
continue
self.logger.warning(
'Dropped invalid phone # and/or Group '
'({}) specified.'.format(target),
)
return
def send(self, body, title='', notify_type=NotifyType.INFO, **kwargs):
"""
Perform BulkSMS Notification
"""
if not (self.password and self.user):
self.logger.warning(
'There were no valid login credentials provided')
return False
if not (self.targets or self.groups):
# We have nothing to notify
self.logger.warning('There are no Twist targets to notify')
return False
# Send in batches if identified to do so
batch_size = 1 if not self.batch else self.default_batch_size
# error tracking (used for function return)
has_error = False
# Prepare our headers
headers = {
'User-Agent': self.app_id,
'Content-Type': 'application/json',
}
# Prepare our payload
payload = {
# The To gets populated in the loop below
'to': None,
'body': body,
'routingGroup': self.route,
'encoding': BulkSMSEncoding.UNICODE \
if self.unicode else BulkSMSEncoding.TEXT,
# Options are NONE, ALL and ERRORS
'deliveryReports': "ERRORS"
}
if self.source:
payload.update({
'from': self.source,
})
# Authentication
auth = (self.user, self.password)
# Prepare our targets
targets = list(self.targets) if batch_size == 1 else \
[self.targets[index:index + batch_size]
for index in range(0, len(self.targets), batch_size)]
targets += [{"type": "GROUP", "name": g} for g in self.groups]
while len(targets):
# Get our target to notify
target = targets.pop(0)
# Prepare our user
payload['to'] = target
# Printable reference
if isinstance(target, dict):
p_target = target['name']
elif isinstance(target, list):
p_target = '{} targets'.format(len(target))
else:
p_target = target
# Some Debug Logging
self.logger.debug('BulkSMS POST URL: {} (cert_verify={})'.format(
self.notify_url, self.verify_certificate))
self.logger.debug('BulkSMS Payload: {}' .format(payload))
# Always call throttle before any remote server i/o is made
self.throttle()
try:
r = requests.post(
self.notify_url,
data=json.dumps(payload),
headers=headers,
auth=auth,
verify=self.verify_certificate,
timeout=self.request_timeout,
)
# The responsne might look like:
# [
# {
# "id": "string",
# "type": "SENT",
# "from": "string",
# "to": "string",
# "body": null,
# "encoding": "TEXT",
# "protocolId": 0,
# "messageClass": 0,
# "numberOfParts": 0,
# "creditCost": 0,
# "submission": {...},
# "status": {...},
# "relatedSentMessageId": "string",
# "userSuppliedId": "string"
# }
# ]
if r.status_code not in (
requests.codes.created, requests.codes.ok):
# We had a problem
status_str = \
NotifyBase.http_response_code_lookup(r.status_code)
# set up our status code to use
status_code = r.status_code
self.logger.warning(
'Failed to send BulkSMS notification to {}: '
'{}{}error={}.'.format(
p_target,
status_str,
', ' if status_str else '',
status_code))
self.logger.debug(
'Response Details:\r\n{}'.format(r.content))
# Mark our failure
has_error = True
continue
else:
self.logger.info(
'Sent BulkSMS notification to {}.'.format(p_target))
except requests.RequestException as e:
self.logger.warning(
'A Connection error occurred sending BulkSMS: to %s ',
p_target)
self.logger.debug('Socket Exception: %s' % str(e))
# Mark our failure
has_error = True
continue
return not has_error
def url(self, privacy=False, *args, **kwargs):
"""
Returns the URL built dynamically based on specified arguments.
"""
# Define any URL parameters
params = {
'unicode': 'yes' if self.unicode else 'no',
'batch': 'yes' if self.batch else 'no',
'route': self.route,
}
if self.source:
params['from'] = self.source
# Extend our parameters
params.update(self.url_parameters(privacy=privacy, *args, **kwargs))
return '{schema}://{user}:{password}@{targets}/?{params}'.format(
schema=self.secure_protocol,
user=self.pprint(self.user, privacy, safe=''),
password=self.pprint(
self.password, privacy, mode=PrivacyMode.Secret, safe=''),
targets='/'.join(chain(
[NotifyBulkSMS.quote('{}'.format(x), safe='+')
for x in self.targets],
[NotifyBulkSMS.quote('@{}'.format(x), safe='@')
for x in self.groups])),
params=NotifyBulkSMS.urlencode(params))
@staticmethod
def parse_url(url):
"""
Parses the URL and returns enough arguments that can allow
us to re-instantiate this object.
"""
results = NotifyBase.parse_url(url, verify_host=False)
if not results:
# We're done early as we couldn't load the results
return results
# Get our entries; split_path() looks after unquoting content for us
# by default
results['targets'] = [
NotifyBulkSMS.unquote(results['host']),
*NotifyBulkSMS.split_path(results['fullpath'])]
# Support the 'from' and 'source' variable so that we can support
# targets this way too.
# The 'from' makes it easier to use yaml configuration
if 'from' in results['qsd'] and len(results['qsd']['from']):
results['source'] = \
NotifyBulkSMS.unquote(results['qsd']['from'])
# Support the 'to' variable so that we can support targets this way too
# The 'to' makes it easier to use yaml configuration
if 'to' in results['qsd'] and len(results['qsd']['to']):
results['targets'] += \
NotifyBulkSMS.parse_phone_no(results['qsd']['to'])
# Unicode Characters
results['unicode'] = \
parse_bool(results['qsd'].get(
'unicode', NotifyBulkSMS.template_args['unicode']['default']))
# Get Batch Mode Flag
results['batch'] = \
parse_bool(results['qsd'].get(
'batch', NotifyBulkSMS.template_args['batch']['default']))
# Allow one to define a route group
if 'route' in results['qsd'] and len(results['qsd']['route']):
results['route'] = \
NotifyBulkSMS.unquote(results['qsd']['route'])
return results

View file

@ -30,7 +30,6 @@
# (both user and password) from the API Details section from within your # (both user and password) from the API Details section from within your
# account profile area: https://d7networks.com/accounts/profile/ # account profile area: https://d7networks.com/accounts/profile/
import six
import requests import requests
import base64 import base64
from json import dumps from json import dumps
@ -54,7 +53,7 @@ D7NETWORKS_HTTP_ERROR_MAP = {
# Priorities # Priorities
class D7SMSPriority(object): class D7SMSPriority:
""" """
D7 Networks SMS Message Priority D7 Networks SMS Message Priority
""" """
@ -192,7 +191,7 @@ class NotifyD7Networks(NotifyBase):
# Setup our source address (if defined) # Setup our source address (if defined)
self.source = None \ self.source = None \
if not isinstance(source, six.string_types) else source.strip() if not isinstance(source, str) else source.strip()
if not (self.user and self.password): if not (self.user and self.password):
msg = 'A D7 Networks user/pass was not provided.' msg = 'A D7 Networks user/pass was not provided.'
@ -232,10 +231,10 @@ class NotifyD7Networks(NotifyBase):
auth = '{user}:{password}'.format( auth = '{user}:{password}'.format(
user=self.user, password=self.password) user=self.user, password=self.password)
if six.PY3:
# Python 3's versio of b64encode() expects a byte array and not # Python 3's versio of b64encode() expects a byte array and not
# a string. To accomodate this, we encode the content here # a string. To accommodate this, we encode the content here
auth = auth.encode('utf-8') auth = auth.encode('utf-8')
# Prepare our headers # Prepare our headers
headers = { headers = {

View file

@ -60,7 +60,7 @@ try:
from dbus.mainloop.glib import DBusGMainLoop from dbus.mainloop.glib import DBusGMainLoop
LOOP_GLIB = DBusGMainLoop() LOOP_GLIB = DBusGMainLoop()
except ImportError: except ImportError: # pragma: no cover
# No problem # No problem
pass pass
@ -109,18 +109,36 @@ MAINLOOP_MAP = {
# Urgencies # Urgencies
class DBusUrgency(object): class DBusUrgency:
LOW = 0 LOW = 0
NORMAL = 1 NORMAL = 1
HIGH = 2 HIGH = 2
# Define our urgency levels DBUS_URGENCIES = {
DBUS_URGENCIES = ( # Note: This also acts as a reverse lookup mapping
DBusUrgency.LOW, DBusUrgency.LOW: 'low',
DBusUrgency.NORMAL, DBusUrgency.NORMAL: 'normal',
DBusUrgency.HIGH, DBusUrgency.HIGH: 'high',
) }
DBUS_URGENCY_MAP = {
# Maps against string 'low'
'l': DBusUrgency.LOW,
# Maps against string 'moderate'
'm': DBusUrgency.LOW,
# Maps against string 'normal'
'n': DBusUrgency.NORMAL,
# Maps against string 'high'
'h': DBusUrgency.HIGH,
# Maps against string 'emergency'
'e': DBusUrgency.HIGH,
# Entries to additionally support (so more like DBus's API)
'0': DBusUrgency.LOW,
'1': DBusUrgency.NORMAL,
'2': DBusUrgency.HIGH,
}
class NotifyDBus(NotifyBase): class NotifyDBus(NotifyBase):
@ -143,10 +161,11 @@ class NotifyDBus(NotifyBase):
service_url = 'http://www.freedesktop.org/Software/dbus/' service_url = 'http://www.freedesktop.org/Software/dbus/'
# The default protocols # The default protocols
# Python 3 keys() does not return a list object, it's it's own dict_keys() # Python 3 keys() does not return a list object, it is its own dict_keys()
# object if we were to reference, we wouldn't be backwards compatible with # object if we were to reference, we wouldn't be backwards compatible with
# Python v2. So converting the result set back into a list makes us # Python v2. So converting the result set back into a list makes us
# compatible # compatible
# TODO: Review after dropping support for Python 2.
protocol = list(MAINLOOP_MAP.keys()) protocol = list(MAINLOOP_MAP.keys())
# A URL that takes you to the setup/help of the specific protocol # A URL that takes you to the setup/help of the specific protocol
@ -182,6 +201,12 @@ class NotifyDBus(NotifyBase):
'values': DBUS_URGENCIES, 'values': DBUS_URGENCIES,
'default': DBusUrgency.NORMAL, 'default': DBusUrgency.NORMAL,
}, },
'priority': {
# Apprise uses 'priority' everywhere; it's just a nice consistent
# feel to be able to use it here as well. Just map the
# value back to 'priority'
'alias_of': 'urgency',
},
'x': { 'x': {
'name': _('X-Axis'), 'name': _('X-Axis'),
'type': 'int', 'type': 'int',
@ -223,15 +248,29 @@ class NotifyDBus(NotifyBase):
raise TypeError(msg) raise TypeError(msg)
# The urgency of the message # The urgency of the message
if urgency not in DBUS_URGENCIES: self.urgency = int(
self.urgency = DBusUrgency.NORMAL NotifyDBus.template_args['urgency']['default']
if urgency is None else
else: next((
self.urgency = urgency v for k, v in DBUS_URGENCY_MAP.items()
if str(urgency).lower().startswith(k)),
NotifyDBus.template_args['urgency']['default']))
# Our x/y axis settings # Our x/y axis settings
self.x_axis = x_axis if isinstance(x_axis, int) else None if x_axis or y_axis:
self.y_axis = y_axis if isinstance(y_axis, int) else None try:
self.x_axis = int(x_axis)
self.y_axis = int(y_axis)
except (TypeError, ValueError):
# Invalid x/y values specified
msg = 'The x,y coordinates specified ({},{}) are invalid.'\
.format(x_axis, y_axis)
self.logger.warning(msg)
raise TypeError(msg)
else:
self.x_axis = None
self.y_axis = None
# Track whether or not we want to send an image with our notification # Track whether or not we want to send an image with our notification
# or not. # or not.
@ -343,17 +382,13 @@ class NotifyDBus(NotifyBase):
Returns the URL built dynamically based on specified arguments. Returns the URL built dynamically based on specified arguments.
""" """
_map = {
DBusUrgency.LOW: 'low',
DBusUrgency.NORMAL: 'normal',
DBusUrgency.HIGH: 'high',
}
# Define any URL parameters # Define any URL parameters
params = { params = {
'image': 'yes' if self.include_image else 'no', 'image': 'yes' if self.include_image else 'no',
'urgency': 'normal' if self.urgency not in _map 'urgency':
else _map[self.urgency], DBUS_URGENCIES[self.template_args['urgency']['default']]
if self.urgency not in DBUS_URGENCIES
else DBUS_URGENCIES[self.urgency],
} }
# Extend our parameters # Extend our parameters
@ -389,38 +424,20 @@ class NotifyDBus(NotifyBase):
# DBus supports urgency, but we we also support the keyword priority # DBus supports urgency, but we we also support the keyword priority
# so that it is consistent with some of the other plugins # so that it is consistent with some of the other plugins
urgency = results['qsd'].get('urgency', results['qsd'].get('priority')) if 'priority' in results['qsd'] and len(results['qsd']['priority']):
if urgency and len(urgency): # We intentionally store the priority in the urgency section
_map = { results['urgency'] = \
'0': DBusUrgency.LOW, NotifyDBus.unquote(results['qsd']['priority'])
'l': DBusUrgency.LOW,
'n': DBusUrgency.NORMAL,
'1': DBusUrgency.NORMAL,
'h': DBusUrgency.HIGH,
'2': DBusUrgency.HIGH,
}
try: if 'urgency' in results['qsd'] and len(results['qsd']['urgency']):
# Attempt to index/retrieve our urgency results['urgency'] = \
results['urgency'] = _map[urgency[0].lower()] NotifyDBus.unquote(results['qsd']['urgency'])
except KeyError:
# No priority was set
pass
# handle x,y coordinates # handle x,y coordinates
try: if 'x' in results['qsd'] and len(results['qsd']['x']):
results['x_axis'] = int(results['qsd'].get('x')) results['x_axis'] = NotifyDBus.unquote(results['qsd'].get('x'))
except (TypeError, ValueError): if 'y' in results['qsd'] and len(results['qsd']['y']):
# No x was set results['y_axis'] = NotifyDBus.unquote(results['qsd'].get('y'))
pass
try:
results['y_axis'] = int(results['qsd'].get('y'))
except (TypeError, ValueError):
# No y was set
pass
return results return results

View file

@ -58,15 +58,27 @@ from ..utils import parse_list
from ..utils import parse_bool from ..utils import parse_bool
class DapnetPriority(object): class DapnetPriority:
NORMAL = 0 NORMAL = 0
EMERGENCY = 1 EMERGENCY = 1
DAPNET_PRIORITIES = ( DAPNET_PRIORITIES = {
DapnetPriority.NORMAL, DapnetPriority.NORMAL: 'normal',
DapnetPriority.EMERGENCY, DapnetPriority.EMERGENCY: 'emergency',
) }
DAPNET_PRIORITY_MAP = {
# Maps against string 'normal'
'n': DapnetPriority.NORMAL,
# Maps against string 'emergency'
'e': DapnetPriority.EMERGENCY,
# Entries to additionally support (so more like Dapnet's API)
'0': DapnetPriority.NORMAL,
'1': DapnetPriority.EMERGENCY,
}
class NotifyDapnet(NotifyBase): class NotifyDapnet(NotifyBase):
@ -172,11 +184,14 @@ class NotifyDapnet(NotifyBase):
# Parse our targets # Parse our targets
self.targets = list() self.targets = list()
# get the emergency prio setting # The Priority of the message
if priority not in DAPNET_PRIORITIES: self.priority = int(
self.priority = self.template_args['priority']['default'] NotifyDapnet.template_args['priority']['default']
else: if priority is None else
self.priority = priority next((
v for k, v in DAPNET_PRIORITY_MAP.items()
if str(priority).lower().startswith(k)),
NotifyDapnet.template_args['priority']['default']))
if not (self.user and self.password): if not (self.user and self.password):
msg = 'A Dapnet user/pass was not provided.' msg = 'A Dapnet user/pass was not provided.'
@ -201,8 +216,7 @@ class NotifyDapnet(NotifyBase):
) )
continue continue
# Store callsign without SSID and # Store callsign without SSID and ignore duplicates
# ignore duplicates
if result['callsign'] not in self.targets: if result['callsign'] not in self.targets:
self.targets.append(result['callsign']) self.targets.append(result['callsign'])
@ -230,10 +244,6 @@ class NotifyDapnet(NotifyBase):
# error tracking (used for function return) # error tracking (used for function return)
has_error = False has_error = False
# prepare the emergency mode
emergency_mode = True \
if self.priority == DapnetPriority.EMERGENCY else False
# Create a copy of the targets list # Create a copy of the targets list
targets = list(self.targets) targets = list(self.targets)
@ -244,7 +254,7 @@ class NotifyDapnet(NotifyBase):
'text': body, 'text': body,
'callSignNames': targets[index:index + batch_size], 'callSignNames': targets[index:index + batch_size],
'transmitterGroupNames': self.txgroups, 'transmitterGroupNames': self.txgroups,
'emergency': emergency_mode, 'emergency': (self.priority == DapnetPriority.EMERGENCY),
} }
self.logger.debug('DAPNET POST URL: %s' % self.notify_url) self.logger.debug('DAPNET POST URL: %s' % self.notify_url)
@ -304,16 +314,12 @@ class NotifyDapnet(NotifyBase):
Returns the URL built dynamically based on specified arguments. Returns the URL built dynamically based on specified arguments.
""" """
# Define any URL parameters
_map = {
DapnetPriority.NORMAL: 'normal',
DapnetPriority.EMERGENCY: 'emergency',
}
# Define any URL parameters # Define any URL parameters
params = { params = {
'priority': 'normal' if self.priority not in _map 'priority':
else _map[self.priority], DAPNET_PRIORITIES[self.template_args['priority']['default']]
if self.priority not in DAPNET_PRIORITIES
else DAPNET_PRIORITIES[self.priority],
'batch': 'yes' if self.batch else 'no', 'batch': 'yes' if self.batch else 'no',
'txgroups': ','.join(self.txgroups), 'txgroups': ','.join(self.txgroups),
} }
@ -361,25 +367,10 @@ class NotifyDapnet(NotifyBase):
results['targets'] += \ results['targets'] += \
NotifyDapnet.parse_list(results['qsd']['to']) NotifyDapnet.parse_list(results['qsd']['to'])
# Check for priority # Set our priority
if 'priority' in results['qsd'] and len(results['qsd']['priority']): if 'priority' in results['qsd'] and len(results['qsd']['priority']):
_map = { results['priority'] = \
# Letter Assignments NotifyDapnet.unquote(results['qsd']['priority'])
'n': DapnetPriority.NORMAL,
'e': DapnetPriority.EMERGENCY,
'no': DapnetPriority.NORMAL,
'em': DapnetPriority.EMERGENCY,
# Numeric assignments
'0': DapnetPriority.NORMAL,
'1': DapnetPriority.EMERGENCY,
}
try:
results['priority'] = \
_map[results['qsd']['priority'][0:2].lower()]
except KeyError:
# No priority was set
pass
# Check for one or multiple transmitter groups (comma separated) # Check for one or multiple transmitter groups (comma separated)
# and split them up, when necessary # and split them up, when necessary

View file

@ -128,6 +128,12 @@ class NotifyDiscord(NotifyBase):
'name': _('Avatar URL'), 'name': _('Avatar URL'),
'type': 'string', 'type': 'string',
}, },
# Send a message to the specified thread within a webhook's channel.
# The thread will automatically be unarchived.
'thread': {
'name': _('Thread ID'),
'type': 'string',
},
'footer': { 'footer': {
'name': _('Display Footer'), 'name': _('Display Footer'),
'type': 'bool', 'type': 'bool',
@ -153,7 +159,7 @@ class NotifyDiscord(NotifyBase):
def __init__(self, webhook_id, webhook_token, tts=False, avatar=True, def __init__(self, webhook_id, webhook_token, tts=False, avatar=True,
footer=False, footer_logo=True, include_image=False, footer=False, footer_logo=True, include_image=False,
fields=True, avatar_url=None, **kwargs): fields=True, avatar_url=None, thread=None, **kwargs):
""" """
Initialize Discord Object Initialize Discord Object
@ -194,6 +200,9 @@ class NotifyDiscord(NotifyBase):
# Use Fields # Use Fields
self.fields = fields self.fields = fields
# Specified Thread ID
self.thread_id = thread
# Avatar URL # Avatar URL
# This allows a user to provide an over-ride to the otherwise # This allows a user to provide an over-ride to the otherwise
# dynamically generated avatar url images # dynamically generated avatar url images
@ -274,6 +283,9 @@ class NotifyDiscord(NotifyBase):
payload['content'] = \ payload['content'] = \
body if not title else "{}\r\n{}".format(title, body) body if not title else "{}\r\n{}".format(title, body)
if self.thread_id:
payload['thread_id'] = self.thread_id
if self.avatar and (image_url or self.avatar_url): if self.avatar and (image_url or self.avatar_url):
payload['avatar_url'] = \ payload['avatar_url'] = \
self.avatar_url if self.avatar_url else image_url self.avatar_url if self.avatar_url else image_url
@ -447,6 +459,9 @@ class NotifyDiscord(NotifyBase):
if self.avatar_url: if self.avatar_url:
params['avatar_url'] = self.avatar_url params['avatar_url'] = self.avatar_url
if self.thread_id:
params['thread'] = self.thread_id
# Extend our parameters # Extend our parameters
params.update(self.url_parameters(privacy=privacy, *args, **kwargs)) params.update(self.url_parameters(privacy=privacy, *args, **kwargs))
@ -515,6 +530,11 @@ class NotifyDiscord(NotifyBase):
results['avatar_url'] = \ results['avatar_url'] = \
NotifyDiscord.unquote(results['qsd']['avatar_url']) NotifyDiscord.unquote(results['qsd']['avatar_url'])
# Extract thread id if it was specified
if 'thread' in results['qsd']:
results['thread'] = \
NotifyDiscord.unquote(results['qsd']['thread'])
return results return results
@staticmethod @staticmethod

View file

@ -24,12 +24,11 @@
# THE SOFTWARE. # THE SOFTWARE.
import re import re
import six
import smtplib import smtplib
from email.mime.text import MIMEText from email.mime.text import MIMEText
from email.mime.application import MIMEApplication from email.mime.application import MIMEApplication
from email.mime.multipart import MIMEMultipart from email.mime.multipart import MIMEMultipart
from email.utils import formataddr from email.utils import formataddr, make_msgid
from email.header import Header from email.header import Header
from email import charset from email import charset
@ -38,17 +37,16 @@ from datetime import datetime
from .NotifyBase import NotifyBase from .NotifyBase import NotifyBase
from ..URLBase import PrivacyMode from ..URLBase import PrivacyMode
from ..common import NotifyFormat from ..common import NotifyFormat, NotifyType
from ..common import NotifyType from ..conversion import convert_between
from ..utils import is_email from ..utils import is_email, parse_emails
from ..utils import parse_emails
from ..AppriseLocale import gettext_lazy as _ from ..AppriseLocale import gettext_lazy as _
# Globally Default encoding mode set to Quoted Printable. # Globally Default encoding mode set to Quoted Printable.
charset.add_charset('utf-8', charset.QP, charset.QP, 'utf-8') charset.add_charset('utf-8', charset.QP, charset.QP, 'utf-8')
class WebBaseLogin(object): class WebBaseLogin:
""" """
This class is just used in conjunction of the default emailers This class is just used in conjunction of the default emailers
to best formulate a login to it using the data detected to best formulate a login to it using the data detected
@ -61,7 +59,7 @@ class WebBaseLogin(object):
# Secure Email Modes # Secure Email Modes
class SecureMailMode(object): class SecureMailMode:
SSL = "ssl" SSL = "ssl"
STARTTLS = "starttls" STARTTLS = "starttls"
@ -91,21 +89,6 @@ EMAIL_TEMPLATES = (
}, },
), ),
# Pronto Mail
(
'Pronto Mail',
re.compile(
r'^((?P<label>[^+]+)\+)?(?P<id>[^@]+)@'
r'(?P<domain>prontomail\.com)$', re.I),
{
'port': 465,
'smtp_host': 'secure.emailsrvr.com',
'secure': True,
'secure_mode': SecureMailMode.STARTTLS,
'login_type': (WebBaseLogin.EMAIL, )
},
),
# Yandex # Yandex
( (
'Yandex', 'Yandex',
@ -126,7 +109,7 @@ EMAIL_TEMPLATES = (
'Microsoft Hotmail', 'Microsoft Hotmail',
re.compile( re.compile(
r'^((?P<label>[^+]+)\+)?(?P<id>[^@]+)@' r'^((?P<label>[^+]+)\+)?(?P<id>[^@]+)@'
r'(?P<domain>(hotmail|live)\.com)$', re.I), r'(?P<domain>(outlook|hotmail|live)\.com(\.au)?)$', re.I),
{ {
'port': 587, 'port': 587,
'smtp_host': 'smtp-mail.outlook.com', 'smtp_host': 'smtp-mail.outlook.com',
@ -412,6 +395,11 @@ class NotifyEmail(NotifyBase):
'default': SecureMailMode.STARTTLS, 'default': SecureMailMode.STARTTLS,
'map_to': 'secure_mode', 'map_to': 'secure_mode',
}, },
'reply': {
'name': _('Reply To'),
'type': 'list:string',
'map_to': 'reply_to',
},
}) })
# Define any kwargs we're using # Define any kwargs we're using
@ -424,7 +412,7 @@ class NotifyEmail(NotifyBase):
def __init__(self, smtp_host=None, from_name=None, def __init__(self, smtp_host=None, from_name=None,
from_addr=None, secure_mode=None, targets=None, cc=None, from_addr=None, secure_mode=None, targets=None, cc=None,
bcc=None, headers=None, **kwargs): bcc=None, reply_to=None, headers=None, **kwargs):
""" """
Initialize Email Object Initialize Email Object
@ -450,6 +438,9 @@ class NotifyEmail(NotifyBase):
# Acquire Blind Carbon Copies # Acquire Blind Carbon Copies
self.bcc = set() self.bcc = set()
# Acquire Reply To
self.reply_to = set()
# For tracking our email -> name lookups # For tracking our email -> name lookups
self.names = {} self.names = {}
@ -482,13 +473,17 @@ class NotifyEmail(NotifyBase):
# Set our from name # Set our from name
self.from_name = from_name if from_name else result['name'] self.from_name = from_name if from_name else result['name']
# Store our lookup
self.names[self.from_addr] = \
self.from_name if self.from_name else False
# Now detect the SMTP Server # Now detect the SMTP Server
self.smtp_host = \ self.smtp_host = \
smtp_host if isinstance(smtp_host, six.string_types) else '' smtp_host if isinstance(smtp_host, str) else ''
# Now detect secure mode # Now detect secure mode
self.secure_mode = self.default_secure_mode \ self.secure_mode = self.default_secure_mode \
if not isinstance(secure_mode, six.string_types) \ if not isinstance(secure_mode, str) \
else secure_mode.lower() else secure_mode.lower()
if self.secure_mode not in SECURE_MODES: if self.secure_mode not in SECURE_MODES:
msg = 'The secure mode specified ({}) is invalid.'\ msg = 'The secure mode specified ({}) is invalid.'\
@ -548,8 +543,24 @@ class NotifyEmail(NotifyBase):
'({}) specified.'.format(recipient), '({}) specified.'.format(recipient),
) )
# Validate recipients (reply-to:) and drop bad ones:
for recipient in parse_emails(reply_to):
email = is_email(recipient)
if email:
self.reply_to.add(email['full_email'])
# Index our name (if one exists)
self.names[email['full_email']] = \
email['name'] if email['name'] else False
continue
self.logger.warning(
'Dropped invalid Reply To email '
'({}) specified.'.format(recipient),
)
# Apply any defaults based on certain known configurations # Apply any defaults based on certain known configurations
self.NotifyEmailDefaults() self.NotifyEmailDefaults(secure_mode=secure_mode, **kwargs)
# if there is still no smtp_host then we fall back to the hostname # if there is still no smtp_host then we fall back to the hostname
if not self.smtp_host: if not self.smtp_host:
@ -557,7 +568,7 @@ class NotifyEmail(NotifyBase):
return return
def NotifyEmailDefaults(self): def NotifyEmailDefaults(self, secure_mode=None, port=None, **kwargs):
""" """
A function that prefills defaults based on the email A function that prefills defaults based on the email
it was provided. it was provided.
@ -586,18 +597,23 @@ class NotifyEmail(NotifyBase):
'Applying %s Defaults' % 'Applying %s Defaults' %
EMAIL_TEMPLATES[i][0], EMAIL_TEMPLATES[i][0],
) )
self.port = EMAIL_TEMPLATES[i][2]\ # the secure flag can not be altered if defined in the template
.get('port', self.port)
self.secure = EMAIL_TEMPLATES[i][2]\ self.secure = EMAIL_TEMPLATES[i][2]\
.get('secure', self.secure) .get('secure', self.secure)
self.secure_mode = EMAIL_TEMPLATES[i][2]\
.get('secure_mode', self.secure_mode) # The SMTP Host check is already done above; if it was
# specified we wouldn't even reach this part of the code.
self.smtp_host = EMAIL_TEMPLATES[i][2]\ self.smtp_host = EMAIL_TEMPLATES[i][2]\
.get('smtp_host', self.smtp_host) .get('smtp_host', self.smtp_host)
if self.smtp_host is None: # The following can be over-ridden if defined manually in the
# default to our host # Apprise URL. Otherwise they take on the template value
self.smtp_host = self.host if not port:
self.port = EMAIL_TEMPLATES[i][2]\
.get('port', self.port)
if not secure_mode:
self.secure_mode = EMAIL_TEMPLATES[i][2]\
.get('secure_mode', self.secure_mode)
# Adjust email login based on the defined usertype. If no entry # Adjust email login based on the defined usertype. If no entry
# was specified, then we default to having them all set (which # was specified, then we default to having them all set (which
@ -622,6 +638,18 @@ class NotifyEmail(NotifyBase):
break break
def _get_charset(self, input_string):
"""
Get utf-8 charset if non ascii string only
Encode an ascii string to utf-8 is bad for email deliverability
because some anti-spam gives a bad score for that
like SUBJ_EXCESS_QP flag on Rspamd
"""
if not input_string:
return None
return 'utf-8' if not all(ord(c) < 128 for c in input_string) else None
def send(self, body, title='', notify_type=NotifyType.INFO, attach=None, def send(self, body, title='', notify_type=NotifyType.INFO, attach=None,
**kwargs): **kwargs):
""" """
@ -652,26 +680,24 @@ class NotifyEmail(NotifyBase):
# Strip target out of bcc list if in To # Strip target out of bcc list if in To
bcc = (self.bcc - set([to_addr])) bcc = (self.bcc - set([to_addr]))
try: # Strip target out of reply_to list if in To
# Format our cc addresses to support the Name field reply_to = (self.reply_to - set([to_addr]))
cc = [formataddr(
# Format our cc addresses to support the Name field
cc = [formataddr(
(self.names.get(addr, False), addr), charset='utf-8')
for addr in cc]
# Format our bcc addresses to support the Name field
bcc = [formataddr(
(self.names.get(addr, False), addr), charset='utf-8')
for addr in bcc]
if reply_to:
# Format our reply-to addresses to support the Name field
reply_to = [formataddr(
(self.names.get(addr, False), addr), charset='utf-8') (self.names.get(addr, False), addr), charset='utf-8')
for addr in cc] for addr in reply_to]
# Format our bcc addresses to support the Name field
bcc = [formataddr(
(self.names.get(addr, False), addr), charset='utf-8')
for addr in bcc]
except TypeError:
# Python v2.x Support (no charset keyword)
# Format our cc addresses to support the Name field
cc = [formataddr( # pragma: no branch
(self.names.get(addr, False), addr)) for addr in cc]
# Format our bcc addresses to support the Name field
bcc = [formataddr( # pragma: no branch
(self.names.get(addr, False), addr)) for addr in bcc]
self.logger.debug( self.logger.debug(
'Email From: {} <{}>'.format(from_name, self.from_addr)) 'Email From: {} <{}>'.format(from_name, self.from_addr))
@ -680,45 +706,29 @@ class NotifyEmail(NotifyBase):
self.logger.debug('Email Cc: {}'.format(', '.join(cc))) self.logger.debug('Email Cc: {}'.format(', '.join(cc)))
if bcc: if bcc:
self.logger.debug('Email Bcc: {}'.format(', '.join(bcc))) self.logger.debug('Email Bcc: {}'.format(', '.join(bcc)))
if reply_to:
self.logger.debug(
'Email Reply-To: {}'.format(', '.join(reply_to))
)
self.logger.debug('Login ID: {}'.format(self.user)) self.logger.debug('Login ID: {}'.format(self.user))
self.logger.debug( self.logger.debug(
'Delivery: {}:{}'.format(self.smtp_host, self.port)) 'Delivery: {}:{}'.format(self.smtp_host, self.port))
# Prepare Email Message # Prepare Email Message
if self.notify_format == NotifyFormat.HTML: if self.notify_format == NotifyFormat.HTML:
content = MIMEText(body, 'html', 'utf-8') base = MIMEMultipart("alternative")
base.attach(MIMEText(
convert_between(
NotifyFormat.HTML, NotifyFormat.TEXT, body),
'plain', 'utf-8')
)
base.attach(MIMEText(body, 'html', 'utf-8'))
else: else:
content = MIMEText(body, 'plain', 'utf-8') base = MIMEText(body, 'plain', 'utf-8')
base = MIMEMultipart() if attach else content
# Apply any provided custom headers
for k, v in self.headers.items():
base[k] = Header(v, 'utf-8')
base['Subject'] = Header(title, 'utf-8')
try:
base['From'] = formataddr(
(from_name if from_name else False, self.from_addr),
charset='utf-8')
base['To'] = formataddr((to_name, to_addr), charset='utf-8')
except TypeError:
# Python v2.x Support (no charset keyword)
base['From'] = formataddr(
(from_name if from_name else False, self.from_addr))
base['To'] = formataddr((to_name, to_addr))
base['Cc'] = ','.join(cc)
base['Date'] = \
datetime.utcnow().strftime("%a, %d %b %Y %H:%M:%S +0000")
base['X-Application'] = self.app_id
if attach: if attach:
# First attach our body to our content as the first element mixed = MIMEMultipart("mixed")
base.attach(content) mixed.attach(base)
# Now store our attachments # Now store our attachments
for attachment in attach: for attachment in attach:
if not attachment: if not attachment:
@ -745,8 +755,28 @@ class NotifyEmail(NotifyBase):
'attachment; filename="{}"'.format( 'attachment; filename="{}"'.format(
Header(attachment.name, 'utf-8')), Header(attachment.name, 'utf-8')),
) )
mixed.attach(app)
base = mixed
base.attach(app) # Apply any provided custom headers
for k, v in self.headers.items():
base[k] = Header(v, self._get_charset(v))
base['Subject'] = Header(title, self._get_charset(title))
base['From'] = formataddr(
(from_name if from_name else False, self.from_addr),
charset='utf-8')
base['To'] = formataddr((to_name, to_addr), charset='utf-8')
base['Message-ID'] = make_msgid(domain=self.smtp_host)
base['Date'] = \
datetime.utcnow().strftime("%a, %d %b %Y %H:%M:%S +0000")
base['X-Application'] = self.app_id
if cc:
base['Cc'] = ','.join(cc)
if reply_to:
base['Reply-To'] = ','.join(reply_to)
# bind the socket variable to the current namespace # bind the socket variable to the current namespace
socket = None socket = None
@ -839,6 +869,14 @@ class NotifyEmail(NotifyBase):
'' if not e not in self.names '' if not e not in self.names
else '{}:'.format(self.names[e]), e) for e in self.bcc]) else '{}:'.format(self.names[e]), e) for e in self.bcc])
if self.reply_to:
# Handle our Reply-To Addresses
params['reply'] = ','.join(
['{}{}'.format(
'' if not e not in self.names
else '{}:'.format(self.names[e]), e)
for e in self.reply_to])
# pull email suffix from username (if present) # pull email suffix from username (if present)
user = None if not self.user else self.user.split('@')[0] user = None if not self.user else self.user.split('@')[0]
@ -916,13 +954,6 @@ class NotifyEmail(NotifyBase):
# Extract from name to associate with from address # Extract from name to associate with from address
results['from_name'] = NotifyEmail.unquote(results['qsd']['name']) results['from_name'] = NotifyEmail.unquote(results['qsd']['name'])
if 'timeout' in results['qsd'] and len(results['qsd']['timeout']):
# Deprecated in favor of cto= flag
NotifyBase.logger.deprecate(
"timeout= argument is deprecated; use cto= instead.")
results['qsd']['cto'] = results['qsd']['timeout']
del results['qsd']['timeout']
# Store SMTP Host if specified # Store SMTP Host if specified
if 'smtp' in results['qsd'] and len(results['qsd']['smtp']): if 'smtp' in results['qsd'] and len(results['qsd']['smtp']):
# Extract the smtp server # Extract the smtp server
@ -940,6 +971,10 @@ class NotifyEmail(NotifyBase):
if 'bcc' in results['qsd'] and len(results['qsd']['bcc']): if 'bcc' in results['qsd'] and len(results['qsd']['bcc']):
results['bcc'] = results['qsd']['bcc'] results['bcc'] = results['qsd']['bcc']
# Handle Reply To Addresses
if 'reply' in results['qsd'] and len(results['qsd']['reply']):
results['reply_to'] = results['qsd']['reply']
results['from_addr'] = from_addr results['from_addr'] = from_addr
results['smtp_host'] = smtp_host results['smtp_host'] = smtp_host

View file

@ -677,7 +677,7 @@ class NotifyEmby(NotifyBase):
def __del__(self): def __del__(self):
""" """
Deconstructor Destructor
""" """
try: try:
self.logout() self.logout()
@ -694,20 +694,20 @@ class NotifyEmby(NotifyBase):
# - https://bugs.python.org/issue29288 # - https://bugs.python.org/issue29288
# #
# A ~similar~ issue can be identified here in the requests # A ~similar~ issue can be identified here in the requests
# ticket system as unresolved and has provided work-arounds # ticket system as unresolved and has provided workarounds
# - https://github.com/kennethreitz/requests/issues/3578 # - https://github.com/kennethreitz/requests/issues/3578
pass pass
except ImportError: # pragma: no cover except ImportError: # pragma: no cover
# The actual exception is `ModuleNotFoundError` however ImportError # The actual exception is `ModuleNotFoundError` however ImportError
# grants us backwards compatiblity with versions of Python older # grants us backwards compatibility with versions of Python older
# than v3.6 # than v3.6
# Python code that makes early calls to sys.exit() can cause # Python code that makes early calls to sys.exit() can cause
# the __del__() code to run. However in some newer versions of # the __del__() code to run. However, in some newer versions of
# Python, this causes the `sys` library to no longer be # Python, this causes the `sys` library to no longer be
# available. The stack overflow also goes on to suggest that # available. The stack overflow also goes on to suggest that
# it's not wise to use the __del__() as a deconstructor # it's not wise to use the __del__() as a destructor
# which is the case here. # which is the case here.
# https://stackoverflow.com/questions/67218341/\ # https://stackoverflow.com/questions/67218341/\
@ -719,6 +719,6 @@ class NotifyEmby(NotifyBase):
# /1481488/what-is-the-del-method-and-how-do-i-call-it # /1481488/what-is-the-del-method-and-how-do-i-call-it
# At this time it seems clean to try to log out (if we can) # At this time it seems clean to try to log out (if we can)
# but not throw any unessisary exceptions (like this one) to # but not throw any unnecessary exceptions (like this one) to
# the end user if we don't have to. # the end user if we don't have to.
pass pass

View file

@ -31,7 +31,6 @@
# - https://github.com/E2OpenPlugins/e2openplugin-OpenWebif/wiki/\ # - https://github.com/E2OpenPlugins/e2openplugin-OpenWebif/wiki/\
# OpenWebif-API-documentation#message # OpenWebif-API-documentation#message
import six
import requests import requests
from json import loads from json import loads
@ -41,7 +40,7 @@ from ..common import NotifyType
from ..AppriseLocale import gettext_lazy as _ from ..AppriseLocale import gettext_lazy as _
class Enigma2MessageType(object): class Enigma2MessageType:
# Defines the Enigma2 notification types Apprise can map to # Defines the Enigma2 notification types Apprise can map to
INFO = 1 INFO = 1
WARNING = 2 WARNING = 2
@ -169,7 +168,7 @@ class NotifyEnigma2(NotifyBase):
self.timeout = self.template_args['timeout']['default'] self.timeout = self.template_args['timeout']['default']
self.fullpath = kwargs.get('fullpath') self.fullpath = kwargs.get('fullpath')
if not isinstance(self.fullpath, six.string_types): if not isinstance(self.fullpath, str):
self.fullpath = '/' self.fullpath = '/'
self.headers = {} self.headers = {}
@ -337,18 +336,10 @@ class NotifyEnigma2(NotifyBase):
return results return results
# Add our headers that the user can potentially over-ride if they wish # Add our headers that the user can potentially over-ride if they wish
# to to our returned result set # to to our returned result set and tidy entries by unquoting them
results['headers'] = results['qsd+']
if results['qsd-']:
results['headers'].update(results['qsd-'])
NotifyBase.logger.deprecate(
"minus (-) based Enigma header tokens are being "
" removed; use the plus (+) symbol instead.")
# Tidy our header entries by unquoting them
results['headers'] = { results['headers'] = {
NotifyEnigma2.unquote(x): NotifyEnigma2.unquote(y) NotifyEnigma2.unquote(x): NotifyEnigma2.unquote(y)
for x, y in results['headers'].items()} for x, y in results['qsd+'].items()}
# Save timeout value (if specified) # Save timeout value (if specified)
if 'timeout' in results['qsd'] and len(results['qsd']['timeout']): if 'timeout' in results['qsd'] and len(results['qsd']['timeout']):

View file

@ -45,7 +45,6 @@
# #
# If you Generate a new private key, it will provide a .json file # If you Generate a new private key, it will provide a .json file
# You will need this in order to send an apprise messag # You will need this in order to send an apprise messag
import six
import requests import requests
from json import dumps from json import dumps
from ..NotifyBase import NotifyBase from ..NotifyBase import NotifyBase
@ -53,6 +52,7 @@ from ...common import NotifyType
from ...utils import validate_regex from ...utils import validate_regex
from ...utils import parse_list from ...utils import parse_list
from ...utils import parse_bool from ...utils import parse_bool
from ...utils import dict_full_update
from ...common import NotifyImageSize from ...common import NotifyImageSize
from ...AppriseAttachment import AppriseAttachment from ...AppriseAttachment import AppriseAttachment
from ...AppriseLocale import gettext_lazy as _ from ...AppriseLocale import gettext_lazy as _
@ -73,7 +73,7 @@ except ImportError:
# cryptography is the dependency of the .oauth library # cryptography is the dependency of the .oauth library
# Create a dummy object for init() call to work # Create a dummy object for init() call to work
class GoogleOAuth(object): class GoogleOAuth:
pass pass
@ -227,7 +227,7 @@ class NotifyFCM(NotifyBase):
else: else:
# Setup our mode # Setup our mode
self.mode = NotifyFCM.template_tokens['mode']['default'] \ self.mode = NotifyFCM.template_tokens['mode']['default'] \
if not isinstance(mode, six.string_types) else mode.lower() if not isinstance(mode, str) else mode.lower()
if self.mode and self.mode not in FCM_MODES: if self.mode and self.mode not in FCM_MODES:
msg = 'The FCM mode specified ({}) is invalid.'.format(mode) msg = 'The FCM mode specified ({}) is invalid.'.format(mode)
self.logger.warning(msg) self.logger.warning(msg)
@ -450,17 +450,9 @@ class NotifyFCM(NotifyBase):
"FCM recipient %s parsed as a device token", "FCM recipient %s parsed as a device token",
recipient) recipient)
# # A more advanced dict.update() that recursively includes
# Apply our priority configuration (if set) # sub-dictionaries as well
# dict_full_update(payload, self.priority.payload())
def merge(d1, d2):
for k in d2:
if k in d1 and isinstance(d1[k], dict) \
and isinstance(d2[k], dict):
merge(d1[k], d2[k])
else:
d1[k] = d2[k]
merge(payload, self.priority.payload())
self.logger.debug( self.logger.debug(
'FCM %s POST URL: %s (cert_verify=%r)', 'FCM %s POST URL: %s (cert_verify=%r)',

View file

@ -31,13 +31,12 @@
# https://firebase.google.com/docs/reference/fcm/rest/v1/\ # https://firebase.google.com/docs/reference/fcm/rest/v1/\
# projects.messages#androidnotification # projects.messages#androidnotification
import re import re
import six
from ...utils import parse_bool from ...utils import parse_bool
from ...common import NotifyType from ...common import NotifyType
from ...AppriseAsset import AppriseAsset from ...AppriseAsset import AppriseAsset
class FCMColorManager(object): class FCMColorManager:
""" """
A Simple object to accept either a boolean value A Simple object to accept either a boolean value
- True: Use colors provided by Apprise - True: Use colors provided by Apprise
@ -63,7 +62,7 @@ class FCMColorManager(object):
# Prepare our color # Prepare our color
self.color = color self.color = color
if isinstance(color, six.string_types): if isinstance(color, str):
self.color = self.__color_rgb.match(color) self.color = self.__color_rgb.match(color)
if self.color: if self.color:
# Store our RGB value as #rrggbb # Store our RGB value as #rrggbb
@ -112,16 +111,8 @@ class FCMColorManager(object):
def __bool__(self): def __bool__(self):
""" """
Allows this object to be wrapped in an Python 3.x based 'if Allows this object to be wrapped in an 'if statement'.
statement'. True is returned if a color was loaded True is returned if a color was loaded
""" """
return True if self.color is True or \ return True if self.color is True or \
isinstance(self.color, six.string_types) else False isinstance(self.color, str) else False
def __nonzero__(self):
"""
Allows this object to be wrapped in an Python 2.x based 'if
statement'. True is returned if a color was loaded
"""
return True if self.color is True or \
isinstance(self.color, six.string_types) else False

View file

@ -22,7 +22,7 @@
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, # 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 # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
# THE SOFTWARE. # THE SOFTWARE.
class FCMMode(object): class FCMMode:
""" """
Define the Firebase Cloud Messaging Modes Define the Firebase Cloud Messaging Modes
""" """

View file

@ -29,7 +29,6 @@
# 2. Click Generate New Private Key, then confirm by clicking Generate Key. # 2. Click Generate New Private Key, then confirm by clicking Generate Key.
# 3. Securely store the JSON file containing the key. # 3. Securely store the JSON file containing the key.
import io
import requests import requests
import base64 import base64
import json import json
@ -41,26 +40,13 @@ from cryptography.hazmat.primitives import asymmetric
from cryptography.exceptions import UnsupportedAlgorithm from cryptography.exceptions import UnsupportedAlgorithm
from datetime import datetime from datetime import datetime
from datetime import timedelta from datetime import timedelta
from json.decoder import JSONDecodeError
from urllib.parse import urlencode as _urlencode
from ...logger import logger from ...logger import logger
try:
# Python 2.7
from urllib import urlencode as _urlencode
except ImportError: class GoogleOAuth:
# Python 3.x
from urllib.parse import urlencode as _urlencode
try:
# Python 3.x
from json.decoder import JSONDecodeError
except ImportError:
# Python v2.7 Backwards Compatibility support
JSONDecodeError = ValueError
class GoogleOAuth(object):
""" """
A OAuth simplified implimentation to Google's Firebase Cloud Messaging A OAuth simplified implimentation to Google's Firebase Cloud Messaging
@ -127,7 +113,7 @@ class GoogleOAuth(object):
self.__access_token_expiry = datetime.utcnow() self.__access_token_expiry = datetime.utcnow()
try: try:
with io.open(path, mode="r", encoding=self.encoding) as fp: with open(path, mode="r", encoding=self.encoding) as fp:
self.content = json.loads(fp.read()) self.content = json.loads(fp.read())
except (OSError, IOError): except (OSError, IOError):

View file

@ -33,7 +33,7 @@ from .common import (FCMMode, FCM_MODES)
from ...logger import logger from ...logger import logger
class NotificationPriority(object): class NotificationPriority:
""" """
Defines the Notification Priorities as described on: Defines the Notification Priorities as described on:
https://firebase.google.com/docs/reference/fcm/rest/v1/\ https://firebase.google.com/docs/reference/fcm/rest/v1/\
@ -63,7 +63,7 @@ class NotificationPriority(object):
HIGH = 'HIGH' HIGH = 'HIGH'
class FCMPriority(object): class FCMPriority:
""" """
Defines our accepted priorites Defines our accepted priorites
""" """
@ -87,7 +87,7 @@ FCM_PRIORITIES = (
) )
class FCMPriorityManager(object): class FCMPriorityManager:
""" """
A Simple object to make it easier to work with FCM set priorities A Simple object to make it easier to work with FCM set priorities
""" """
@ -242,14 +242,7 @@ class FCMPriorityManager(object):
def __bool__(self): def __bool__(self):
""" """
Allows this object to be wrapped in an Python 3.x based 'if Allows this object to be wrapped in an 'if statement'.
statement'. True is returned if a priority was loaded True is returned if a priority was loaded
"""
return True if self.priority else False
def __nonzero__(self):
"""
Allows this object to be wrapped in an Python 2.x based 'if
statement'. True is returned if a priority was loaded
""" """
return True if self.priority else False return True if self.priority else False

View file

@ -23,7 +23,6 @@
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
# THE SOFTWARE. # THE SOFTWARE.
import six
import requests import requests
from .NotifyBase import NotifyBase from .NotifyBase import NotifyBase
@ -137,11 +136,11 @@ class NotifyForm(NotifyBase):
super(NotifyForm, self).__init__(**kwargs) super(NotifyForm, self).__init__(**kwargs)
self.fullpath = kwargs.get('fullpath') self.fullpath = kwargs.get('fullpath')
if not isinstance(self.fullpath, six.string_types): if not isinstance(self.fullpath, str):
self.fullpath = '' self.fullpath = ''
self.method = self.template_args['method']['default'] \ self.method = self.template_args['method']['default'] \
if not isinstance(method, six.string_types) else method.upper() if not isinstance(method, str) else method.upper()
if self.method not in METHODS: if self.method not in METHODS:
msg = 'The method specified ({}) is invalid.'.format(method) msg = 'The method specified ({}) is invalid.'.format(method)
@ -374,17 +373,9 @@ class NotifyForm(NotifyBase):
for x, y in results['qsd:'].items()} for x, y in results['qsd:'].items()}
# Add our headers that the user can potentially over-ride if they wish # Add our headers that the user can potentially over-ride if they wish
# to to our returned result set # to to our returned result set and tidy entries by unquoting them
results['headers'] = results['qsd+']
if results['qsd-']:
results['headers'].update(results['qsd-'])
NotifyBase.logger.deprecate(
"minus (-) based Form header tokens are being "
" removed; use the plus (+) symbol instead.")
# Tidy our header entries by unquoting them
results['headers'] = {NotifyForm.unquote(x): NotifyForm.unquote(y) results['headers'] = {NotifyForm.unquote(x): NotifyForm.unquote(y)
for x, y in results['headers'].items()} for x, y in results['qsd+'].items()}
# Set method if not otherwise set # Set method if not otherwise set
if 'method' in results['qsd'] and len(results['qsd']['method']): if 'method' in results['qsd'] and len(results['qsd']['method']):

View file

@ -60,17 +60,36 @@ except (ImportError, ValueError, AttributeError):
# Urgencies # Urgencies
class GnomeUrgency(object): class GnomeUrgency:
LOW = 0 LOW = 0
NORMAL = 1 NORMAL = 1
HIGH = 2 HIGH = 2
GNOME_URGENCIES = ( GNOME_URGENCIES = {
GnomeUrgency.LOW, GnomeUrgency.LOW: 'low',
GnomeUrgency.NORMAL, GnomeUrgency.NORMAL: 'normal',
GnomeUrgency.HIGH, GnomeUrgency.HIGH: 'high',
) }
GNOME_URGENCY_MAP = {
# Maps against string 'low'
'l': GnomeUrgency.LOW,
# Maps against string 'moderate'
'm': GnomeUrgency.LOW,
# Maps against string 'normal'
'n': GnomeUrgency.NORMAL,
# Maps against string 'high'
'h': GnomeUrgency.HIGH,
# Maps against string 'emergency'
'e': GnomeUrgency.HIGH,
# Entries to additionally support (so more like Gnome's API)
'0': GnomeUrgency.LOW,
'1': GnomeUrgency.NORMAL,
'2': GnomeUrgency.HIGH,
}
class NotifyGnome(NotifyBase): class NotifyGnome(NotifyBase):
@ -126,6 +145,12 @@ class NotifyGnome(NotifyBase):
'values': GNOME_URGENCIES, 'values': GNOME_URGENCIES,
'default': GnomeUrgency.NORMAL, 'default': GnomeUrgency.NORMAL,
}, },
'priority': {
# Apprise uses 'priority' everywhere; it's just a nice consistent
# feel to be able to use it here as well. Just map the
# value back to 'priority'
'alias_of': 'urgency',
},
'image': { 'image': {
'name': _('Include Image'), 'name': _('Include Image'),
'type': 'bool', 'type': 'bool',
@ -142,11 +167,13 @@ class NotifyGnome(NotifyBase):
super(NotifyGnome, self).__init__(**kwargs) super(NotifyGnome, self).__init__(**kwargs)
# The urgency of the message # The urgency of the message
if urgency not in GNOME_URGENCIES: self.urgency = int(
self.urgency = self.template_args['urgency']['default'] NotifyGnome.template_args['urgency']['default']
if urgency is None else
else: next((
self.urgency = urgency v for k, v in GNOME_URGENCY_MAP.items()
if str(urgency).lower().startswith(k)),
NotifyGnome.template_args['urgency']['default']))
# Track whether or not we want to send an image with our notification # Track whether or not we want to send an image with our notification
# or not. # or not.
@ -205,17 +232,13 @@ class NotifyGnome(NotifyBase):
Returns the URL built dynamically based on specified arguments. Returns the URL built dynamically based on specified arguments.
""" """
_map = {
GnomeUrgency.LOW: 'low',
GnomeUrgency.NORMAL: 'normal',
GnomeUrgency.HIGH: 'high',
}
# Define any URL parameters # Define any URL parameters
params = { params = {
'image': 'yes' if self.include_image else 'no', 'image': 'yes' if self.include_image else 'no',
'urgency': 'normal' if self.urgency not in _map 'urgency':
else _map[self.urgency], GNOME_URGENCIES[self.template_args['urgency']['default']]
if self.urgency not in GNOME_URGENCIES
else GNOME_URGENCIES[self.urgency],
} }
# Extend our parameters # Extend our parameters
@ -243,23 +266,13 @@ class NotifyGnome(NotifyBase):
# Gnome supports urgency, but we we also support the keyword priority # Gnome supports urgency, but we we also support the keyword priority
# so that it is consistent with some of the other plugins # so that it is consistent with some of the other plugins
urgency = results['qsd'].get('urgency', results['qsd'].get('priority')) if 'priority' in results['qsd'] and len(results['qsd']['priority']):
if urgency and len(urgency): # We intentionally store the priority in the urgency section
_map = { results['urgency'] = \
'0': GnomeUrgency.LOW, NotifyGnome.unquote(results['qsd']['priority'])
'l': GnomeUrgency.LOW,
'n': GnomeUrgency.NORMAL,
'1': GnomeUrgency.NORMAL,
'h': GnomeUrgency.HIGH,
'2': GnomeUrgency.HIGH,
}
try: if 'urgency' in results['qsd'] and len(results['qsd']['urgency']):
# Attempt to index/retrieve our urgency results['urgency'] = \
results['urgency'] = _map[urgency[0].lower()] NotifyGnome.unquote(results['qsd']['urgency'])
except KeyError:
# No priority was set
pass
return results return results

View file

@ -41,7 +41,7 @@ from ..AppriseLocale import gettext_lazy as _
# Priorities # Priorities
class GotifyPriority(object): class GotifyPriority:
LOW = 0 LOW = 0
MODERATE = 3 MODERATE = 3
NORMAL = 5 NORMAL = 5
@ -49,13 +49,37 @@ class GotifyPriority(object):
EMERGENCY = 10 EMERGENCY = 10
GOTIFY_PRIORITIES = ( GOTIFY_PRIORITIES = {
GotifyPriority.LOW, # Note: This also acts as a reverse lookup mapping
GotifyPriority.MODERATE, GotifyPriority.LOW: 'low',
GotifyPriority.NORMAL, GotifyPriority.MODERATE: 'moderate',
GotifyPriority.HIGH, GotifyPriority.NORMAL: 'normal',
GotifyPriority.EMERGENCY, GotifyPriority.HIGH: 'high',
) GotifyPriority.EMERGENCY: 'emergency',
}
GOTIFY_PRIORITY_MAP = {
# Maps against string 'low'
'l': GotifyPriority.LOW,
# Maps against string 'moderate'
'm': GotifyPriority.MODERATE,
# Maps against string 'normal'
'n': GotifyPriority.NORMAL,
# Maps against string 'high'
'h': GotifyPriority.HIGH,
# Maps against string 'emergency'
'e': GotifyPriority.EMERGENCY,
# Entries to additionally support (so more like Gotify's API)
'10': GotifyPriority.EMERGENCY,
# ^ 10 needs to be checked before '1' below or it will match the wrong
# priority
'0': GotifyPriority.LOW, '1': GotifyPriority.LOW, '2': GotifyPriority.LOW,
'3': GotifyPriority.MODERATE, '4': GotifyPriority.MODERATE,
'5': GotifyPriority.NORMAL, '6': GotifyPriority.NORMAL,
'7': GotifyPriority.NORMAL,
'8': GotifyPriority.HIGH, '9': GotifyPriority.HIGH,
}
class NotifyGotify(NotifyBase): class NotifyGotify(NotifyBase):
@ -144,11 +168,14 @@ class NotifyGotify(NotifyBase):
# prepare our fullpath # prepare our fullpath
self.fullpath = kwargs.get('fullpath', '/') self.fullpath = kwargs.get('fullpath', '/')
if priority not in GOTIFY_PRIORITIES: # The Priority of the message
self.priority = GotifyPriority.NORMAL self.priority = int(
NotifyGotify.template_args['priority']['default']
else: if priority is None else
self.priority = priority next((
v for k, v in GOTIFY_PRIORITY_MAP.items()
if str(priority).lower().startswith(k)),
NotifyGotify.template_args['priority']['default']))
if self.secure: if self.secure:
self.schema = 'https' self.schema = 'https'
@ -246,7 +273,10 @@ class NotifyGotify(NotifyBase):
# Define any URL parameters # Define any URL parameters
params = { params = {
'priority': self.priority, 'priority':
GOTIFY_PRIORITIES[self.template_args['priority']['default']]
if self.priority not in GOTIFY_PRIORITIES
else GOTIFY_PRIORITIES[self.priority],
} }
# Extend our parameters # Extend our parameters
@ -294,20 +324,9 @@ class NotifyGotify(NotifyBase):
results['fullpath'] = \ results['fullpath'] = \
'/' if not entries else '/{}/'.format('/'.join(entries)) '/' if not entries else '/{}/'.format('/'.join(entries))
# Set our priority
if 'priority' in results['qsd'] and len(results['qsd']['priority']): if 'priority' in results['qsd'] and len(results['qsd']['priority']):
_map = { results['priority'] = \
'l': GotifyPriority.LOW, NotifyGotify.unquote(results['qsd']['priority'])
'm': GotifyPriority.MODERATE,
'n': GotifyPriority.NORMAL,
'h': GotifyPriority.HIGH,
'e': GotifyPriority.EMERGENCY,
}
try:
results['priority'] = \
_map[results['qsd']['priority'][0].lower()]
except KeyError:
# No priority was set
pass
return results return results

View file

@ -46,7 +46,7 @@ except ImportError:
# Priorities # Priorities
class GrowlPriority(object): class GrowlPriority:
LOW = -2 LOW = -2
MODERATE = -1 MODERATE = -1
NORMAL = 0 NORMAL = 0
@ -54,13 +54,34 @@ class GrowlPriority(object):
EMERGENCY = 2 EMERGENCY = 2
GROWL_PRIORITIES = ( GROWL_PRIORITIES = {
GrowlPriority.LOW, # Note: This also acts as a reverse lookup mapping
GrowlPriority.MODERATE, GrowlPriority.LOW: 'low',
GrowlPriority.NORMAL, GrowlPriority.MODERATE: 'moderate',
GrowlPriority.HIGH, GrowlPriority.NORMAL: 'normal',
GrowlPriority.EMERGENCY, GrowlPriority.HIGH: 'high',
) GrowlPriority.EMERGENCY: 'emergency',
}
GROWL_PRIORITY_MAP = {
# Maps against string 'low'
'l': GrowlPriority.LOW,
# Maps against string 'moderate'
'm': GrowlPriority.MODERATE,
# Maps against string 'normal'
'n': GrowlPriority.NORMAL,
# Maps against string 'high'
'h': GrowlPriority.HIGH,
# Maps against string 'emergency'
'e': GrowlPriority.EMERGENCY,
# Entries to additionally support (so more like Growl's API)
'-2': GrowlPriority.LOW,
'-1': GrowlPriority.MODERATE,
'0': GrowlPriority.NORMAL,
'1': GrowlPriority.HIGH,
'2': GrowlPriority.EMERGENCY,
}
class NotifyGrowl(NotifyBase): class NotifyGrowl(NotifyBase):
@ -172,11 +193,12 @@ class NotifyGrowl(NotifyBase):
self.port = self.default_port self.port = self.default_port
# The Priority of the message # The Priority of the message
if priority not in GROWL_PRIORITIES: self.priority = NotifyGrowl.template_args['priority']['default'] \
self.priority = GrowlPriority.NORMAL if not priority else \
next((
else: v for k, v in GROWL_PRIORITY_MAP.items()
self.priority = priority if str(priority).lower().startswith(k)),
NotifyGrowl.template_args['priority']['default'])
# Our Registered object # Our Registered object
self.growl = None self.growl = None
@ -318,21 +340,14 @@ class NotifyGrowl(NotifyBase):
Returns the URL built dynamically based on specified arguments. Returns the URL built dynamically based on specified arguments.
""" """
_map = {
GrowlPriority.LOW: 'low',
GrowlPriority.MODERATE: 'moderate',
GrowlPriority.NORMAL: 'normal',
GrowlPriority.HIGH: 'high',
GrowlPriority.EMERGENCY: 'emergency',
}
# Define any URL parameters # Define any URL parameters
params = { params = {
'image': 'yes' if self.include_image else 'no', 'image': 'yes' if self.include_image else 'no',
'sticky': 'yes' if self.sticky else 'no', 'sticky': 'yes' if self.sticky else 'no',
'priority': 'priority':
_map[GrowlPriority.NORMAL] if self.priority not in _map GROWL_PRIORITIES[self.template_args['priority']['default']]
else _map[self.priority], if self.priority not in GROWL_PRIORITIES
else GROWL_PRIORITIES[self.priority],
'version': self.version, 'version': self.version,
} }
@ -384,33 +399,10 @@ class NotifyGrowl(NotifyBase):
) )
pass pass
# Set our priority
if 'priority' in results['qsd'] and len(results['qsd']['priority']): if 'priority' in results['qsd'] and len(results['qsd']['priority']):
_map = { results['priority'] = \
# Letter Assignments NotifyGrowl.unquote(results['qsd']['priority'])
'l': GrowlPriority.LOW,
'm': GrowlPriority.MODERATE,
'n': GrowlPriority.NORMAL,
'h': GrowlPriority.HIGH,
'e': GrowlPriority.EMERGENCY,
'lo': GrowlPriority.LOW,
'me': GrowlPriority.MODERATE,
'no': GrowlPriority.NORMAL,
'hi': GrowlPriority.HIGH,
'em': GrowlPriority.EMERGENCY,
# Support 3rd Party Documented Scale
'-2': GrowlPriority.LOW,
'-1': GrowlPriority.MODERATE,
'0': GrowlPriority.NORMAL,
'1': GrowlPriority.HIGH,
'2': GrowlPriority.EMERGENCY,
}
try:
results['priority'] = \
_map[results['qsd']['priority'][0:2].lower()]
except KeyError:
# No priority was set
pass
# Because of the URL formatting, the password is actually where the # Because of the URL formatting, the password is actually where the
# username field is. For this reason, we just preform this small hack # username field is. For this reason, we just preform this small hack

View file

@ -0,0 +1,91 @@
# -*- 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.
# For this to work correctly you need to create a webhook. To do this just
# click on the little gear icon next to the channel you're part of. From
# here you'll be able to access the Webhooks menu and create a new one.
#
# When you've completed, you'll get a URL that looks a little like this:
# https://media.guilded.gg/webhooks/417429632418316298/\
# JHZ7lQml277CDHmQKMHI8qBe7bk2ZwO5UKjCiOAF7711o33MyqU344Qpgv7YTpadV_js
#
# Simplified, it looks like this:
# https://media.guilded.gg/webhooks/WEBHOOK_ID/WEBHOOK_TOKEN
#
# This plugin will simply work using the url of:
# guilded://WEBHOOK_ID/WEBHOOK_TOKEN
#
# API Documentation on Webhooks:
# - https://discord.com/developers/docs/resources/webhook
#
import re
from .NotifyDiscord import NotifyDiscord
class NotifyGuilded(NotifyDiscord):
"""
A wrapper to Guilded Notifications
"""
# The default descriptive name associated with the Notification
service_name = 'Guilded'
# The services URL
service_url = 'https://guilded.gg/'
# A URL that takes you to the setup/help of the specific protocol
setup_url = 'https://github.com/caronc/apprise/wiki/Notify_guilded'
# The default secure protocol
secure_protocol = 'guilded'
# Guilded Webhook
notify_url = 'https://media.guilded.gg/webhooks'
@staticmethod
def parse_native_url(url):
"""
Support https://media.guilded.gg/webhooks/WEBHOOK_ID/WEBHOOK_TOKEN
"""
result = re.match(
r'^https?://(media\.)?guilded\.gg/webhooks/'
# a UUID, but we do we really need to be _that_ picky?
r'(?P<webhook_id>[-0-9a-f]+)/'
r'(?P<webhook_token>[A-Z0-9_-]+)/?'
r'(?P<params>\?.+)?$', url, re.I)
if result:
return NotifyGuilded.parse_url(
'{schema}://{webhook_id}/{webhook_token}/{params}'.format(
schema=NotifyGuilded.secure_protocol,
webhook_id=result.group('webhook_id'),
webhook_token=result.group('webhook_token'),
params='' if not result.group('params')
else result.group('params')))
return None

View file

@ -107,7 +107,7 @@ class NotifyHomeAssistant(NotifyBase):
# Optional Unique Notification ID # Optional Unique Notification ID
'name': _('Notification ID'), 'name': _('Notification ID'),
'type': 'string', 'type': 'string',
'regex': (r'^[a-f0-9_-]+$', 'i'), 'regex': (r'^[a-z0-9_-]+$', 'i'),
}, },
}) })

View file

@ -23,7 +23,6 @@
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
# THE SOFTWARE. # THE SOFTWARE.
import six
import requests import requests
import base64 import base64
from json import dumps from json import dumps
@ -139,11 +138,11 @@ class NotifyJSON(NotifyBase):
super(NotifyJSON, self).__init__(**kwargs) super(NotifyJSON, self).__init__(**kwargs)
self.fullpath = kwargs.get('fullpath') self.fullpath = kwargs.get('fullpath')
if not isinstance(self.fullpath, six.string_types): if not isinstance(self.fullpath, str):
self.fullpath = '' self.fullpath = ''
self.method = self.template_args['method']['default'] \ self.method = self.template_args['method']['default'] \
if not isinstance(method, six.string_types) else method.upper() if not isinstance(method, str) else method.upper()
if self.method not in METHODS: if self.method not in METHODS:
msg = 'The method specified ({}) is invalid.'.format(method) msg = 'The method specified ({}) is invalid.'.format(method)
@ -361,17 +360,9 @@ class NotifyJSON(NotifyBase):
for x, y in results['qsd:'].items()} for x, y in results['qsd:'].items()}
# Add our headers that the user can potentially over-ride if they wish # Add our headers that the user can potentially over-ride if they wish
# to to our returned result set # to to our returned result set and tidy entries by unquoting them
results['headers'] = results['qsd+']
if results['qsd-']:
results['headers'].update(results['qsd-'])
NotifyBase.logger.deprecate(
"minus (-) based JSON header tokens are being "
" removed; use the plus (+) symbol instead.")
# Tidy our header entries by unquoting them
results['headers'] = {NotifyJSON.unquote(x): NotifyJSON.unquote(y) results['headers'] = {NotifyJSON.unquote(x): NotifyJSON.unquote(y)
for x, y in results['headers'].items()} for x, y in results['qsd+'].items()}
# Set method if not otherwise set # Set method if not otherwise set
if 'method' in results['qsd'] and len(results['qsd']['method']): if 'method' in results['qsd'] and len(results['qsd']['method']):

View file

@ -63,7 +63,7 @@ JOIN_IMAGE_XY = NotifyImageSize.XY_72
# Priorities # Priorities
class JoinPriority(object): class JoinPriority:
LOW = -2 LOW = -2
MODERATE = -1 MODERATE = -1
NORMAL = 0 NORMAL = 0
@ -71,13 +71,34 @@ class JoinPriority(object):
EMERGENCY = 2 EMERGENCY = 2
JOIN_PRIORITIES = ( JOIN_PRIORITIES = {
JoinPriority.LOW, # Note: This also acts as a reverse lookup mapping
JoinPriority.MODERATE, JoinPriority.LOW: 'low',
JoinPriority.NORMAL, JoinPriority.MODERATE: 'moderate',
JoinPriority.HIGH, JoinPriority.NORMAL: 'normal',
JoinPriority.EMERGENCY, JoinPriority.HIGH: 'high',
) JoinPriority.EMERGENCY: 'emergency',
}
JOIN_PRIORITY_MAP = {
# Maps against string 'low'
'l': JoinPriority.LOW,
# Maps against string 'moderate'
'm': JoinPriority.MODERATE,
# Maps against string 'normal'
'n': JoinPriority.NORMAL,
# Maps against string 'high'
'h': JoinPriority.HIGH,
# Maps against string 'emergency'
'e': JoinPriority.EMERGENCY,
# Entries to additionally support (so more like Join's API)
'-2': JoinPriority.LOW,
'-1': JoinPriority.MODERATE,
'0': JoinPriority.NORMAL,
'1': JoinPriority.HIGH,
'2': JoinPriority.EMERGENCY,
}
class NotifyJoin(NotifyBase): class NotifyJoin(NotifyBase):
@ -189,11 +210,13 @@ class NotifyJoin(NotifyBase):
raise TypeError(msg) raise TypeError(msg)
# The Priority of the message # The Priority of the message
if priority not in JOIN_PRIORITIES: self.priority = int(
self.priority = self.template_args['priority']['default'] NotifyJoin.template_args['priority']['default']
if priority is None else
else: next((
self.priority = priority v for k, v in JOIN_PRIORITY_MAP.items()
if str(priority).lower().startswith(k)),
NotifyJoin.template_args['priority']['default']))
# Prepare a list of targets to store entries into # Prepare a list of targets to store entries into
self.targets = list() self.targets = list()
@ -324,19 +347,12 @@ class NotifyJoin(NotifyBase):
""" """
Returns the URL built dynamically based on specified arguments. 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 URL parameters # Define any URL parameters
params = { params = {
'priority': 'priority':
_map[self.template_args['priority']['default']] JOIN_PRIORITIES[self.template_args['priority']['default']]
if self.priority not in _map else _map[self.priority], if self.priority not in JOIN_PRIORITIES
else JOIN_PRIORITIES[self.priority],
'image': 'yes' if self.include_image else 'no', 'image': 'yes' if self.include_image else 'no',
} }
@ -371,20 +387,8 @@ class NotifyJoin(NotifyBase):
# Set our priority # Set our priority
if 'priority' in results['qsd'] and len(results['qsd']['priority']): if 'priority' in results['qsd'] and len(results['qsd']['priority']):
_map = { results['priority'] = \
'l': JoinPriority.LOW, NotifyJoin.unquote(results['qsd']['priority'])
'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 # Our Devices
results['targets'] = list() results['targets'] = list()

View file

@ -85,7 +85,6 @@
import re import re
import six
import requests import requests
from json import dumps from json import dumps
from .NotifyBase import NotifyBase from .NotifyBase import NotifyBase
@ -104,7 +103,7 @@ LAMETRIC_APP_ID_DETECTOR_RE = re.compile(
LAMETRIC_IS_APP_TOKEN = re.compile(r'^[a-z0-9]{80,}==$', re.I) LAMETRIC_IS_APP_TOKEN = re.compile(r'^[a-z0-9]{80,}==$', re.I)
class LametricMode(object): class LametricMode:
""" """
Define Lametric Notification Modes Define Lametric Notification Modes
""" """
@ -121,7 +120,7 @@ LAMETRIC_MODES = (
) )
class LametricPriority(object): class LametricPriority:
""" """
Priority of the message Priority of the message
""" """
@ -158,7 +157,7 @@ LAMETRIC_PRIORITIES = (
) )
class LametricIconType(object): class LametricIconType:
""" """
Represents the nature of notification. Represents the nature of notification.
""" """
@ -184,7 +183,7 @@ LAMETRIC_ICON_TYPES = (
) )
class LametricSoundCategory(object): class LametricSoundCategory:
""" """
Define Sound Categories Define Sound Categories
""" """
@ -192,7 +191,7 @@ class LametricSoundCategory(object):
ALARMS = "alarms" ALARMS = "alarms"
class LametricSound(object): class LametricSound:
""" """
There are 2 categories of sounds, to make things simple we just lump them There are 2 categories of sounds, to make things simple we just lump them
all togther in one class object. all togther in one class object.
@ -471,7 +470,7 @@ class NotifyLametric(NotifyBase):
super(NotifyLametric, self).__init__(**kwargs) super(NotifyLametric, self).__init__(**kwargs)
self.mode = mode.strip().lower() \ self.mode = mode.strip().lower() \
if isinstance(mode, six.string_types) \ if isinstance(mode, str) \
else self.template_args['mode']['default'] else self.template_args['mode']['default']
# Default Cloud Argument # Default Cloud Argument
@ -543,7 +542,7 @@ class NotifyLametric(NotifyBase):
# assign our icon (if it was defined); we also eliminate # assign our icon (if it was defined); we also eliminate
# any hashtag (#) entries that might be present # any hashtag (#) entries that might be present
self.icon = re.search(r'[#\s]*(?P<value>.+?)\s*$', icon) \ self.icon = re.search(r'[#\s]*(?P<value>.+?)\s*$', icon) \
.group('value') if isinstance(icon, six.string_types) else None .group('value') if isinstance(icon, str) else None
if icon_type not in LAMETRIC_ICON_TYPES: if icon_type not in LAMETRIC_ICON_TYPES:
self.icon_type = self.template_args['icon_type']['default'] self.icon_type = self.template_args['icon_type']['default']
@ -557,7 +556,7 @@ class NotifyLametric(NotifyBase):
cycles > self.template_args['cycles']['min']) else cycles cycles > self.template_args['cycles']['min']) else cycles
self.sound = None self.sound = None
if isinstance(sound, six.string_types): if isinstance(sound, str):
# If sound is set, get it's match # If sound is set, get it's match
self.sound = self.sound_lookup(sound.strip().lower()) self.sound = self.sound_lookup(sound.strip().lower())
if self.sound is None: if self.sound is None:

View file

@ -0,0 +1,309 @@
# -*- coding: utf-8 -*-
#
# Copyright (C) 2022 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.
#
# API Docs: https://developers.line.biz/en/reference/messaging-api/
import requests
import re
from json import dumps
from .NotifyBase import NotifyBase
from ..URLBase import PrivacyMode
from ..common import NotifyType
from ..common import NotifyImageSize
from ..utils import validate_regex
from ..utils import parse_list
from ..utils import parse_bool
from ..AppriseLocale import gettext_lazy as _
# Used to break path apart into list of streams
TARGET_LIST_DELIM = re.compile(r'[ \t\r\n,#\\/]+')
class NotifyLine(NotifyBase):
"""
A wrapper for Line Notifications
"""
# The default descriptive name associated with the Notification
service_name = 'Line'
# The services URL
service_url = 'https://line.me/'
# Secure Protocol
secure_protocol = 'line'
# The URL refererenced for remote Notifications
notify_url = 'https://api.line.me/v2/bot/message/push'
# A URL that takes you to the setup/help of the specific protocol
setup_url = 'https://github.com/caronc/apprise/wiki/Notify_line'
# We don't support titles for Line notifications
title_maxlen = 0
# Maximum body length is 5000
body_maxlen = 5000
# Allows the user to specify the NotifyImageSize object; this is supported
# through the webhook
image_size = NotifyImageSize.XY_128
# Define object templates
templates = (
'{schema}://{token}/{targets}',
)
# Define our template tokens
template_tokens = dict(NotifyBase.template_tokens, **{
'token': {
'name': _('Access Token'),
'type': 'string',
'private': True,
'required': True
},
'target_user': {
'name': _('Target User'),
'type': 'string',
'map_to': 'targets',
},
'targets': {
'name': _('Targets'),
'type': 'list:string',
},
})
# Define our template arguments
template_args = dict(NotifyBase.template_args, **{
'to': {
'alias_of': 'targets',
},
'image': {
'name': _('Include Image'),
'type': 'bool',
'default': True,
'map_to': 'include_image',
},
})
def __init__(self, token, targets=None, include_image=True, **kwargs):
"""
Initialize Line Object
"""
super(NotifyLine, self).__init__(**kwargs)
# Long-Lived Access token (generated from User Profile)
self.token = validate_regex(token)
if not self.token:
msg = 'An invalid Access Token ' \
'({}) was specified.'.format(token)
self.logger.warning(msg)
raise TypeError(msg)
# Display our Apprise Image
self.include_image = include_image
# Set up our targets
self.targets = parse_list(targets)
# A dictionary of cached users
self.__cached_users = dict()
return
def send(self, body, title='', notify_type=NotifyType.INFO, **kwargs):
"""
Send our Line Notification
"""
if len(self.targets) == 0:
# There were no services to notify
self.logger.warning('There were no Line targets to notify.')
return False
# error tracking (used for function return)
has_error = False
# Prepare our headers
headers = {
'User-Agent': self.app_id,
'Content-Type': 'application/json',
'Authorization': 'Bearer {}'.format(self.token),
}
# Prepare our persistent_notification.create payload
payload = {
"to": None,
"messages": [
{
"type": "text",
"text": body,
"sender": {
"name": self.app_id,
}
}
]
}
# Acquire our image url if configured to do so
image_url = None if not self.include_image else \
self.image_url(notify_type)
if image_url:
payload["messages"][0]["sender"]["iconUrl"] = image_url
# Create a copy of the target list
targets = list(self.targets)
while len(targets):
target = targets.pop(0)
payload['to'] = target
self.logger.debug('Line POST URL: %s (cert_verify=%r)' % (
self.notify_url, self.verify_certificate,
))
self.logger.debug('Line 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,
verify=self.verify_certificate,
timeout=self.request_timeout,
)
if r.status_code != requests.codes.ok:
# We had a problem
status_str = \
NotifyLine.http_response_code_lookup(
r.status_code)
self.logger.warning(
'Failed to send Line notification to {}: '
'{}{}error={}.'.format(
target,
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 Line notification to {}.'.format(target))
except requests.RequestException as e:
self.logger.warning(
'A Connection error occurred sending Line '
'notification to {}.'.format(target))
self.logger.debug('Socket Exception: %s' % str(e))
# Mark our failure
has_error = True
continue
return not has_error
def url(self, privacy=False, *args, **kwargs):
"""
Returns the URL built dynamically based on specified arguments.
"""
# Define any URL parameters
params = {
'image': 'yes' if self.include_image else 'no',
}
# Extend our parameters
params.update(self.url_parameters(privacy=privacy, *args, **kwargs))
return '{schema}://{token}/{targets}?{params}'.format(
schema=self.secure_protocol,
# never encode hostname since we're expecting it to be a valid one
token=self.pprint(
self.token, privacy, mode=PrivacyMode.Secret, safe=''),
targets='/'.join(
[self.pprint(x, privacy, safe='') for x in self.targets]),
params=NotifyLine.urlencode(params),
)
@staticmethod
def parse_url(url):
"""
Parses the URL and returns enough arguments that can allow
us to re-instantiate this object.
"""
results = NotifyBase.parse_url(url, verify_host=False)
if not results:
# We're done early as we couldn't load the results
return results
# Get unquoted entries
results['targets'] = NotifyLine.split_path(results['fullpath'])
# The 'token' makes it easier to use yaml configuration
if 'token' in results['qsd'] and len(results['qsd']['token']):
results['token'] = \
NotifyLine.unquote(results['qsd']['token'])
else:
results['token'] = NotifyLine.unquote(results['host'])
# Line Long Lived Tokens included forward slashes in them.
# As a result we need to parse further into our path and look
# for the entry that ends in an equal symbol.
if not results['token'].endswith('='):
for index, entry in enumerate(
list(results['targets']), start=1):
if entry.endswith('='):
# Found
results['token'] += \
'/' + '/'.join(results['targets'][0:index])
results['targets'] = results['targets'][index:]
break
# Include images with our message
results['include_image'] = \
parse_bool(results['qsd'].get('image', True))
# Support the 'to' variable so that we can support rooms this way too
# The 'to' makes it easier to use yaml configuration
if 'to' in results['qsd'] and len(results['qsd']['to']):
results['targets'] += [x for x in filter(
bool, TARGET_LIST_DELIM.split(
NotifyLine.unquote(results['qsd']['to'])))]
return results

View file

@ -32,7 +32,6 @@
# /blob/master/src/paho/mqtt/client.py # /blob/master/src/paho/mqtt/client.py
import ssl import ssl
import re import re
import six
from time import sleep from time import sleep
from datetime import datetime from datetime import datetime
from os.path import isfile from os.path import isfile
@ -46,11 +45,6 @@ from ..AppriseLocale import gettext_lazy as _
# Default our global support flag # Default our global support flag
NOTIFY_MQTT_SUPPORT_ENABLED = False NOTIFY_MQTT_SUPPORT_ENABLED = False
if six.PY2:
# handle Python v2.7 suport
class ConnectionError(Exception):
pass
try: try:
# 3rd party modules # 3rd party modules
import paho.mqtt.client as mqtt import paho.mqtt.client as mqtt

View file

@ -41,7 +41,7 @@ from ..utils import validate_regex
from ..AppriseLocale import gettext_lazy as _ from ..AppriseLocale import gettext_lazy as _
class MSG91Route(object): class MSG91Route:
""" """
Transactional SMS Routes Transactional SMS Routes
route=1 for promotional, route=4 for transactional SMS. route=1 for promotional, route=4 for transactional SMS.
@ -57,7 +57,7 @@ MSG91_ROUTES = (
) )
class MSG91Country(object): class MSG91Country:
""" """
Optional value that can be specified on the MSG91 api Optional value that can be specified on the MSG91 api
""" """

View file

@ -76,6 +76,7 @@
import re import re
import requests import requests
import json import json
from json.decoder import JSONDecodeError
from .NotifyBase import NotifyBase from .NotifyBase import NotifyBase
from ..common import NotifyImageSize from ..common import NotifyImageSize
@ -88,13 +89,6 @@ from ..utils import TemplateType
from ..AppriseAttachment import AppriseAttachment from ..AppriseAttachment import AppriseAttachment
from ..AppriseLocale import gettext_lazy as _ from ..AppriseLocale import gettext_lazy as _
try:
from json.decoder import JSONDecodeError
except ImportError:
# Python v2.7 Backwards Compatibility support
JSONDecodeError = ValueError
class NotifyMSTeams(NotifyBase): class NotifyMSTeams(NotifyBase):
""" """

View file

@ -74,7 +74,7 @@ MAILGUN_HTTP_ERROR_MAP = {
# Priorities # Priorities
class MailgunRegion(object): class MailgunRegion:
US = 'us' US = 'us'
EU = 'eu' EU = 'eu'
@ -383,17 +383,9 @@ class NotifyMailgun(NotifyBase):
return False return False
try: reply_to = formataddr(
reply_to = formataddr( (self.from_name if self.from_name else False,
(self.from_name if self.from_name else False, self.from_addr), charset='utf-8')
self.from_addr), charset='utf-8')
except TypeError:
# Python v2.x Support (no charset keyword)
# Format our cc addresses to support the Name field
reply_to = formataddr(
(self.from_name if self.from_name else False,
self.from_addr))
# Prepare our payload # Prepare our payload
payload = { payload = {
@ -461,33 +453,17 @@ class NotifyMailgun(NotifyBase):
# Strip target out of bcc list if in To # Strip target out of bcc list if in To
bcc = (bcc - set([to_addr[1]])) bcc = (bcc - set([to_addr[1]]))
try: # Prepare our `to`
# Prepare our to to.append(formataddr(to_addr, charset='utf-8'))
to.append(formataddr(to_addr, charset='utf-8'))
except TypeError:
# Python v2.x Support (no charset keyword)
# Format our cc addresses to support the Name field
# Prepare our to
to.append(formataddr(to_addr))
# Prepare our To # Prepare our To
payload['to'] = ','.join(to) payload['to'] = ','.join(to)
if cc: if cc:
try: # Format our cc addresses to support the Name field
# Format our cc addresses to support the Name field payload['cc'] = ','.join([formataddr(
payload['cc'] = ','.join([formataddr( (self.names.get(addr, False), addr), charset='utf-8')
(self.names.get(addr, False), addr), charset='utf-8') for addr in cc])
for addr in cc])
except TypeError:
# Python v2.x Support (no charset keyword)
# Format our cc addresses to support the Name field
payload['cc'] = ','.join([formataddr( # pragma: no branch
(self.names.get(addr, False), addr))
for addr in cc])
# Format our bcc addresses to support the Name field # Format our bcc addresses to support the Name field
if bcc: if bcc:

View file

@ -28,7 +28,6 @@
# - https://github.com/matrix-org/synapse/blob/master/docs/reverse_proxy.rst # - https://github.com/matrix-org/synapse/blob/master/docs/reverse_proxy.rst
# #
import re import re
import six
import requests import requests
from markdown import markdown from markdown import markdown
from json import dumps from json import dumps
@ -67,7 +66,7 @@ IS_ROOM_ID = re.compile(
r'(?P<home_server>[a-z0-9.-]+))?\s*$', re.I) r'(?P<home_server>[a-z0-9.-]+))?\s*$', re.I)
class MatrixMessageType(object): class MatrixMessageType:
""" """
The Matrix Message types The Matrix Message types
""" """
@ -82,7 +81,7 @@ MATRIX_MESSAGE_TYPES = (
) )
class MatrixWebhookMode(object): class MatrixWebhookMode:
# Webhook Mode is disabled # Webhook Mode is disabled
DISABLED = "off" DISABLED = "off"
@ -263,7 +262,7 @@ class NotifyMatrix(NotifyBase):
# Setup our mode # Setup our mode
self.mode = self.template_args['mode']['default'] \ self.mode = self.template_args['mode']['default'] \
if not isinstance(mode, six.string_types) else mode.lower() if not isinstance(mode, str) else mode.lower()
if self.mode and self.mode not in MATRIX_WEBHOOK_MODES: if self.mode and self.mode not in MATRIX_WEBHOOK_MODES:
msg = 'The mode specified ({}) is invalid.'.format(mode) msg = 'The mode specified ({}) is invalid.'.format(mode)
self.logger.warning(msg) self.logger.warning(msg)
@ -271,7 +270,7 @@ class NotifyMatrix(NotifyBase):
# Setup our message type # Setup our message type
self.msgtype = self.template_args['msgtype']['default'] \ self.msgtype = self.template_args['msgtype']['default'] \
if not isinstance(msgtype, six.string_types) else msgtype.lower() if not isinstance(msgtype, str) else msgtype.lower()
if self.msgtype and self.msgtype not in MATRIX_MESSAGE_TYPES: if self.msgtype and self.msgtype not in MATRIX_MESSAGE_TYPES:
msg = 'The msgtype specified ({}) is invalid.'.format(msgtype) msg = 'The msgtype specified ({}) is invalid.'.format(msgtype)
self.logger.warning(msg) self.logger.warning(msg)
@ -411,7 +410,7 @@ class NotifyMatrix(NotifyBase):
""" """
if not hasattr(self, '_re_slack_formatting_rules'): if not hasattr(self, '_re_slack_formatting_rules'):
# Prepare some one-time slack formating variables # Prepare some one-time slack formatting variables
self._re_slack_formatting_map = { self._re_slack_formatting_map = {
# New lines must become the string version # New lines must become the string version
@ -762,7 +761,7 @@ class NotifyMatrix(NotifyBase):
# We can't join a room if we're not logged in # We can't join a room if we're not logged in
return None return None
if not isinstance(room, six.string_types): if not isinstance(room, str):
# Not a supported string # Not a supported string
return None return None
@ -850,7 +849,7 @@ class NotifyMatrix(NotifyBase):
# We can't create a room if we're not logged in # We can't create a room if we're not logged in
return None return None
if not isinstance(room, six.string_types): if not isinstance(room, str):
# Not a supported string # Not a supported string
return None return None
@ -930,7 +929,7 @@ class NotifyMatrix(NotifyBase):
# We can't get a room id if we're not logged in # We can't get a room id if we're not logged in
return None return None
if not isinstance(room, six.string_types): if not isinstance(room, str):
# Not a supported string # Not a supported string
return None return None
@ -1109,20 +1108,20 @@ class NotifyMatrix(NotifyBase):
# - https://bugs.python.org/issue29288 # - https://bugs.python.org/issue29288
# #
# A ~similar~ issue can be identified here in the requests # A ~similar~ issue can be identified here in the requests
# ticket system as unresolved and has provided work-arounds # ticket system as unresolved and has provided workarounds
# - https://github.com/kennethreitz/requests/issues/3578 # - https://github.com/kennethreitz/requests/issues/3578
pass pass
except ImportError: # pragma: no cover except ImportError: # pragma: no cover
# The actual exception is `ModuleNotFoundError` however ImportError # The actual exception is `ModuleNotFoundError` however ImportError
# grants us backwards compatiblity with versions of Python older # grants us backwards compatibility with versions of Python older
# than v3.6 # than v3.6
# Python code that makes early calls to sys.exit() can cause # Python code that makes early calls to sys.exit() can cause
# the __del__() code to run. However in some newer versions of # the __del__() code to run. However, in some newer versions of
# Python, this causes the `sys` library to no longer be # Python, this causes the `sys` library to no longer be
# available. The stack overflow also goes on to suggest that # available. The stack overflow also goes on to suggest that
# it's not wise to use the __del__() as a deconstructor # it's not wise to use the __del__() as a destructor
# which is the case here. # which is the case here.
# https://stackoverflow.com/questions/67218341/\ # https://stackoverflow.com/questions/67218341/\
@ -1134,7 +1133,7 @@ class NotifyMatrix(NotifyBase):
# /1481488/what-is-the-del-method-and-how-do-i-call-it # /1481488/what-is-the-del-method-and-how-do-i-call-it
# At this time it seems clean to try to log out (if we can) # At this time it seems clean to try to log out (if we can)
# but not throw any unessisary exceptions (like this one) to # but not throw any unnecessary exceptions (like this one) to
# the end user if we don't have to. # the end user if we don't have to.
pass pass

View file

@ -33,7 +33,6 @@
# - swap http with mmost # - swap http with mmost
# - drop /hooks/ reference # - drop /hooks/ reference
import six
import requests import requests
from json import dumps from json import dumps
@ -156,7 +155,7 @@ class NotifyMattermost(NotifyBase):
# our full path # our full path
self.fullpath = '' if not isinstance( self.fullpath = '' if not isinstance(
fullpath, six.string_types) else fullpath.strip() fullpath, str) else fullpath.strip()
# Authorization Token (associated with project) # Authorization Token (associated with project)
self.token = validate_regex(token) self.token = validate_regex(token)

View file

@ -331,13 +331,10 @@ class NotifyNextcloud(NotifyBase):
results['version'] = \ results['version'] = \
NotifyNextcloud.unquote(results['qsd']['version']) NotifyNextcloud.unquote(results['qsd']['version'])
# Add our headers that the user can potentially over-ride if they # Add our headers that the user can potentially over-ride if they wish
# wish to to our returned result set # to to our returned result set and tidy entries by unquoting them
results['headers'] = results['qsd+'] results['headers'] = {
if results['qsd-']: NotifyNextcloud.unquote(x): NotifyNextcloud.unquote(y)
results['headers'].update(results['qsd-']) for x, y in results['qsd+'].items()}
NotifyBase.logger.deprecate(
"minus (-) based Nextcloud header tokens are being "
" removed; use the plus (+) symbol instead.")
return results return results

View file

@ -269,13 +269,10 @@ class NotifyNextcloudTalk(NotifyBase):
results['targets'] = \ results['targets'] = \
NotifyNextcloudTalk.split_path(results['fullpath']) NotifyNextcloudTalk.split_path(results['fullpath'])
# Add our headers that the user can potentially over-ride if they # Add our headers that the user can potentially over-ride if they wish
# wish to to our returned result set # to to our returned result set and tidy entries by unquoting them
results['headers'] = results['qsd+'] results['headers'] = {
if results['qsd-']: NotifyNextcloudTalk.unquote(x): NotifyNextcloudTalk.unquote(y)
results['headers'].update(results['qsd-']) for x, y in results['qsd+'].items()}
NotifyBase.logger.deprecate(
"minus (-) based Nextcloud Talk header tokens are being "
" removed; use the plus (+) symbol instead.")
return results return results

View file

@ -37,7 +37,6 @@
# notica://abc123 # notica://abc123
# #
import re import re
import six
import requests import requests
from .NotifyBase import NotifyBase from .NotifyBase import NotifyBase
@ -47,7 +46,7 @@ from ..utils import validate_regex
from ..AppriseLocale import gettext_lazy as _ from ..AppriseLocale import gettext_lazy as _
class NoticaMode(object): class NoticaMode:
""" """
Tracks if we're accessing the notica upstream server or a locally hosted Tracks if we're accessing the notica upstream server or a locally hosted
one. one.
@ -176,7 +175,7 @@ class NotifyNotica(NotifyBase):
# prepare our fullpath # prepare our fullpath
self.fullpath = kwargs.get('fullpath') self.fullpath = kwargs.get('fullpath')
if not isinstance(self.fullpath, six.string_types): if not isinstance(self.fullpath, str):
self.fullpath = '/' self.fullpath = '/'
self.headers = {} self.headers = {}
@ -364,13 +363,11 @@ class NotifyNotica(NotifyBase):
'/' if not entries else '/{}/'.format('/'.join(entries)) '/' if not entries else '/{}/'.format('/'.join(entries))
# Add our headers that the user can potentially over-ride if they # Add our headers that the user can potentially over-ride if they
# wish to to our returned result set # wish to to our returned result set and tidy entries by unquoting
results['headers'] = results['qsd+'] # them
if results['qsd-']: results['headers'] = {
results['headers'].update(results['qsd-']) NotifyNotica.unquote(x): NotifyNotica.unquote(y)
NotifyBase.logger.deprecate( for x, y in results['qsd+'].items()}
"minus (-) based Notica header tokens are being "
" removed; use the plus (+) symbol instead.")
return results return results

View file

@ -48,8 +48,8 @@ from ..utils import validate_regex
from ..AppriseLocale import gettext_lazy as _ from ..AppriseLocale import gettext_lazy as _
class NotificoFormat(object): class NotificoFormat:
# Resets all formating # Resets all formatting
Reset = '\x0F' Reset = '\x0F'
# Formatting # Formatting
@ -59,7 +59,7 @@ class NotificoFormat(object):
BGSwap = '\x16' BGSwap = '\x16'
class NotificoColor(object): class NotificoColor:
# Resets Color # Resets Color
Reset = '\x03' Reset = '\x03'
@ -248,13 +248,13 @@ class NotifyNotifico(NotifyBase):
if self.color: if self.color:
# Colors were specified, make sure we capture and correctly # Colors were specified, make sure we capture and correctly
# allow them to exist inline in the message # allow them to exist inline in the message
# \g<1> is less ambigious than \1 # \g<1> is less ambiguous than \1
body = re.sub(r'\\x03(\d{0,2})', '\x03\g<1>', body) body = re.sub(r'\\x03(\d{0,2})', r'\\x03\g<1>', body)
else: else:
# no colors specified, make sure we strip out any colors found # no colors specified, make sure we strip out any colors found
# to make the string read-able # to make the string read-able
body = re.sub(r'\\x03(\d{1,2}(,[0-9]{1,2})?)?', '', body) body = re.sub(r'\\x03(\d{1,2}(,[0-9]{1,2})?)?', r'', body)
# Prepare our payload # Prepare our payload
payload = { payload = {

View file

@ -1,24 +1,31 @@
# MIT License # -*- coding: utf-8 -*-
#
# Copyright (c) 2022 Joey Espinosa <@particledecay> # Copyright (C) 2022 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 # Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal # of this software and associated documentation files(the "Software"), to deal
# in the Software without restriction, including without limitation the rights # in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell # to use, copy, modify, merge, publish, distribute, sublicense, and / or sell
# copies of the Software, and to permit persons to whom the Software is # copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions: # furnished to do so, subject to the following conditions :
#
# The above copyright notice and this permission notice shall be included in # The above copyright notice and this permission notice shall be included in
# all copies or substantial portions of the Software. # all copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, # 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 # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
# SOFTWARE. # THE SOFTWARE.
# Great sources
# - https://github.com/matrix-org/matrix-python-sdk
# - https://github.com/matrix-org/synapse/blob/master/docs/reverse_proxy.rst
# #
# Examples: # Examples:
# ntfys://my-topic # ntfys://my-topic
@ -27,7 +34,6 @@
# ntfy://ntfy.local.domain/?priority=max # ntfy://ntfy.local.domain/?priority=max
import re import re
import requests import requests
import six
from json import loads from json import loads
from json import dumps from json import dumps
from os.path import basename from os.path import basename
@ -43,7 +49,7 @@ from ..URLBase import PrivacyMode
from ..attachment.AttachBase import AttachBase from ..attachment.AttachBase import AttachBase
class NtfyMode(object): class NtfyMode:
""" """
Define ntfy Notification Modes Define ntfy Notification Modes
""" """
@ -60,7 +66,7 @@ NTFY_MODES = (
) )
class NtfyPriority(object): class NtfyPriority:
""" """
Ntfy Priority Definitions Ntfy Priority Definitions
""" """
@ -79,6 +85,39 @@ NTFY_PRIORITIES = (
NtfyPriority.MIN, NtfyPriority.MIN,
) )
NTFY_PRIORITY_MAP = {
# Maps against string 'low' but maps to Moderate to avoid
# conflicting with actual ntfy mappings
'l': NtfyPriority.LOW,
# Maps against string 'moderate'
'mo': NtfyPriority.LOW,
# Maps against string 'normal'
'n': NtfyPriority.NORMAL,
# Maps against string 'high'
'h': NtfyPriority.HIGH,
# Maps against string 'emergency'
'e': NtfyPriority.MAX,
# Entries to additionally support (so more like Ntfy's API)
# Maps against string 'min'
'mi': NtfyPriority.MIN,
# Maps against string 'max'
'ma': NtfyPriority.MAX,
# Maps against string 'default'
'd': NtfyPriority.NORMAL,
# support 1-5 values as well
'1': NtfyPriority.MIN,
# Maps against string 'moderate'
'2': NtfyPriority.LOW,
# Maps against string 'normal'
'3': NtfyPriority.NORMAL,
# Maps against string 'high'
'4': NtfyPriority.HIGH,
# Maps against string 'emergency'
'5': NtfyPriority.MAX,
}
class NotifyNtfy(NotifyBase): class NotifyNtfy(NotifyBase):
""" """
@ -207,7 +246,7 @@ class NotifyNtfy(NotifyBase):
# Prepare our mode # Prepare our mode
self.mode = mode.strip().lower() \ self.mode = mode.strip().lower() \
if isinstance(mode, six.string_types) \ if isinstance(mode, str) \
else self.template_args['mode']['default'] else self.template_args['mode']['default']
if self.mode not in NTFY_MODES: if self.mode not in NTFY_MODES:
@ -230,18 +269,13 @@ class NotifyNtfy(NotifyBase):
# An email to forward notifications to # An email to forward notifications to
self.email = email self.email = email
# The priority of the message # The Priority of the message
self.priority = NotifyNtfy.template_args['priority']['default'] \
if priority is None: if not priority else \
self.priority = self.template_args['priority']['default'] next((
else: v for k, v in NTFY_PRIORITY_MAP.items()
self.priority = priority if str(priority).lower().startswith(k)),
NotifyNtfy.template_args['priority']['default'])
if self.priority not in NTFY_PRIORITIES:
msg = 'An invalid ntfy Priority ({}) was specified.'.format(
priority)
self.logger.warning(msg)
raise TypeError(msg)
# Any optional tags to attach to the notification # Any optional tags to attach to the notification
self.__tags = parse_list(tags) self.__tags = parse_list(tags)
@ -274,7 +308,7 @@ class NotifyNtfy(NotifyBase):
self.logger.warning('There are no ntfy topics to notify') self.logger.warning('There are no ntfy topics to notify')
return False return False
# Create a copy of the subreddits list # Create a copy of the topics
topics = list(self.topics) topics = list(self.topics)
while len(topics) > 0: while len(topics) > 0:
# Retrieve our topic # Retrieve our topic
@ -558,31 +592,10 @@ class NotifyNtfy(NotifyBase):
# We're done early as we couldn't load the results # We're done early as we couldn't load the results
return results return results
# Set our priority
if 'priority' in results['qsd'] and len(results['qsd']['priority']): if 'priority' in results['qsd'] and len(results['qsd']['priority']):
_map = { results['priority'] = \
# Supported lookups NotifyNtfy.unquote(results['qsd']['priority'])
'mi': NtfyPriority.MIN,
'1': NtfyPriority.MIN,
'l': NtfyPriority.LOW,
'2': NtfyPriority.LOW,
'n': NtfyPriority.NORMAL, # support normal keyword
'd': NtfyPriority.NORMAL, # default keyword
'3': NtfyPriority.NORMAL,
'h': NtfyPriority.HIGH,
'4': NtfyPriority.HIGH,
'ma': NtfyPriority.MAX,
'5': NtfyPriority.MAX,
}
try:
# pretty-format (and update short-format)
results['priority'] = \
_map[results['qsd']['priority'][0:2].lower()]
except KeyError:
# Pass along what was set so it can be handed during
# initialization
results['priority'] = str(results['qsd']['priority'])
pass
if 'attach' in results['qsd'] and len(results['qsd']['attach']): if 'attach' in results['qsd'] and len(results['qsd']['attach']):
results['attach'] = NotifyNtfy.unquote(results['qsd']['attach']) results['attach'] = NotifyNtfy.unquote(results['qsd']['attach'])

View file

@ -74,7 +74,7 @@ OPSGENIE_CATEGORIES = (
# Regions # Regions
class OpsgenieRegion(object): class OpsgenieRegion:
US = 'us' US = 'us'
EU = 'eu' EU = 'eu'
@ -93,7 +93,7 @@ OPSGENIE_REGIONS = (
# Priorities # Priorities
class OpsgeniePriority(object): class OpsgeniePriority:
LOW = 1 LOW = 1
MODERATE = 2 MODERATE = 2
NORMAL = 3 NORMAL = 3
@ -101,13 +101,40 @@ class OpsgeniePriority(object):
EMERGENCY = 5 EMERGENCY = 5
OPSGENIE_PRIORITIES = ( OPSGENIE_PRIORITIES = {
OpsgeniePriority.LOW, # Note: This also acts as a reverse lookup mapping
OpsgeniePriority.MODERATE, OpsgeniePriority.LOW: 'low',
OpsgeniePriority.NORMAL, OpsgeniePriority.MODERATE: 'moderate',
OpsgeniePriority.HIGH, OpsgeniePriority.NORMAL: 'normal',
OpsgeniePriority.EMERGENCY, OpsgeniePriority.HIGH: 'high',
) OpsgeniePriority.EMERGENCY: 'emergency',
}
OPSGENIE_PRIORITY_MAP = {
# Maps against string 'low'
'l': OpsgeniePriority.LOW,
# Maps against string 'moderate'
'm': OpsgeniePriority.MODERATE,
# Maps against string 'normal'
'n': OpsgeniePriority.NORMAL,
# Maps against string 'high'
'h': OpsgeniePriority.HIGH,
# Maps against string 'emergency'
'e': OpsgeniePriority.EMERGENCY,
# Entries to additionally support (so more like Opsgenie's API)
'1': OpsgeniePriority.LOW,
'2': OpsgeniePriority.MODERATE,
'3': OpsgeniePriority.NORMAL,
'4': OpsgeniePriority.HIGH,
'5': OpsgeniePriority.EMERGENCY,
# Support p-prefix
'p1': OpsgeniePriority.LOW,
'p2': OpsgeniePriority.MODERATE,
'p3': OpsgeniePriority.NORMAL,
'p4': OpsgeniePriority.HIGH,
'p5': OpsgeniePriority.EMERGENCY,
}
class NotifyOpsgenie(NotifyBase): class NotifyOpsgenie(NotifyBase):
@ -246,11 +273,12 @@ class NotifyOpsgenie(NotifyBase):
raise TypeError(msg) raise TypeError(msg)
# The Priority of the message # The Priority of the message
if priority not in OPSGENIE_PRIORITIES: self.priority = NotifyOpsgenie.template_args['priority']['default'] \
self.priority = OpsgeniePriority.NORMAL if not priority else \
next((
else: v for k, v in OPSGENIE_PRIORITY_MAP.items()
self.priority = priority if str(priority).lower().startswith(k)),
NotifyOpsgenie.template_args['priority']['default'])
# Store our region # Store our region
try: try:
@ -353,8 +381,8 @@ class NotifyOpsgenie(NotifyBase):
# Initialize our has_error flag # Initialize our has_error flag
has_error = False has_error = False
# We want to manually set the title onto the body if specified # Use body if title not set
title_body = body if not title else '{}: {}'.format(title, body) title_body = body if not title else body
# Create a copy ouf our details object # Create a copy ouf our details object
details = self.details.copy() details = self.details.copy()
@ -374,7 +402,7 @@ class NotifyOpsgenie(NotifyBase):
# limitation # limitation
if len(payload['message']) > self.opsgenie_body_minlen: if len(payload['message']) > self.opsgenie_body_minlen:
payload['message'] = '{}...'.format( payload['message'] = '{}...'.format(
body[:self.opsgenie_body_minlen - 3]) title_body[:self.opsgenie_body_minlen - 3])
if self.__tags: if self.__tags:
payload['tags'] = self.__tags payload['tags'] = self.__tags
@ -450,20 +478,13 @@ class NotifyOpsgenie(NotifyBase):
Returns the URL built dynamically based on specified arguments. Returns the URL built dynamically based on specified arguments.
""" """
_map = {
OpsgeniePriority.LOW: 'low',
OpsgeniePriority.MODERATE: 'moderate',
OpsgeniePriority.NORMAL: 'normal',
OpsgeniePriority.HIGH: 'high',
OpsgeniePriority.EMERGENCY: 'emergency',
}
# Define any URL parameters # Define any URL parameters
params = { params = {
'region': self.region_name, 'region': self.region_name,
'priority': 'priority':
_map[OpsgeniePriority.NORMAL] if self.priority not in _map OPSGENIE_PRIORITIES[self.template_args['priority']['default']]
else _map[self.priority], if self.priority not in OPSGENIE_PRIORITIES
else OPSGENIE_PRIORITIES[self.priority],
'batch': 'yes' if self.batch_size > 1 else 'no', 'batch': 'yes' if self.batch_size > 1 else 'no',
} }
@ -530,38 +551,10 @@ class NotifyOpsgenie(NotifyBase):
results['details'] = {NotifyBase.unquote(x): NotifyBase.unquote(y) results['details'] = {NotifyBase.unquote(x): NotifyBase.unquote(y)
for x, y in results['qsd+'].items()} for x, y in results['qsd+'].items()}
# Set our priority
if 'priority' in results['qsd'] and len(results['qsd']['priority']): if 'priority' in results['qsd'] and len(results['qsd']['priority']):
_map = { results['priority'] = \
# Letter Assignnments NotifyOpsgenie.unquote(results['qsd']['priority'])
'l': OpsgeniePriority.LOW,
'm': OpsgeniePriority.MODERATE,
'n': OpsgeniePriority.NORMAL,
'h': OpsgeniePriority.HIGH,
'e': OpsgeniePriority.EMERGENCY,
'lo': OpsgeniePriority.LOW,
'me': OpsgeniePriority.MODERATE,
'no': OpsgeniePriority.NORMAL,
'hi': OpsgeniePriority.HIGH,
'em': OpsgeniePriority.EMERGENCY,
# Support 3rd Party API Documented Scale
'1': OpsgeniePriority.LOW,
'2': OpsgeniePriority.MODERATE,
'3': OpsgeniePriority.NORMAL,
'4': OpsgeniePriority.HIGH,
'5': OpsgeniePriority.EMERGENCY,
'p1': OpsgeniePriority.LOW,
'p2': OpsgeniePriority.MODERATE,
'p3': OpsgeniePriority.NORMAL,
'p4': OpsgeniePriority.HIGH,
'p5': OpsgeniePriority.EMERGENCY,
}
try:
results['priority'] = \
_map[results['qsd']['priority'][0:2].lower()]
except KeyError:
# No priority was set
pass
# Get Batch Boolean (if set) # Get Batch Boolean (if set)
results['batch'] = \ results['batch'] = \

View file

@ -0,0 +1,500 @@
# -*- coding: utf-8 -*-
#
# Copyright (C) 2022 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.
# API Refererence:
# - https://developer.pagerduty.com/api-reference/\
# 368ae3d938c9e-send-an-event-to-pager-duty
#
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 validate_regex
from ..utils import parse_bool
from ..AppriseLocale import gettext_lazy as _
class PagerDutySeverity:
"""
Defines the Pager Duty Severity Levels
"""
INFO = 'info'
WARNING = 'warning'
ERROR = 'error'
CRITICAL = 'critical'
# Map all support Apprise Categories with the Pager Duty ones
PAGERDUTY_SEVERITY_MAP = {
NotifyType.INFO: PagerDutySeverity.INFO,
NotifyType.SUCCESS: PagerDutySeverity.INFO,
NotifyType.WARNING: PagerDutySeverity.WARNING,
NotifyType.FAILURE: PagerDutySeverity.CRITICAL,
}
# Priorities
class PagerDutyRegion:
US = 'us'
EU = 'eu'
# SparkPost APIs
PAGERDUTY_API_LOOKUP = {
PagerDutyRegion.US: 'https://events.pagerduty.com/v2/enqueue',
PagerDutyRegion.EU: 'https://events.eu.pagerduty.com/v2/enqueue',
}
# A List of our regions we can use for verification
PAGERDUTY_REGIONS = (
PagerDutyRegion.US,
PagerDutyRegion.EU,
)
class NotifyPagerDuty(NotifyBase):
"""
A wrapper for Pager Duty Notifications
"""
# The default descriptive name associated with the Notification
service_name = 'Pager Duty'
# The services URL
service_url = 'https://pagerduty.com/'
# Secure Protocol
secure_protocol = 'pagerduty'
# A URL that takes you to the setup/help of the specific protocol
setup_url = 'https://github.com/caronc/apprise/wiki/Notify_pagerduty'
# We don't support titles for Pager Duty notifications
title_maxlen = 0
# Allows the user to specify the NotifyImageSize object; this is supported
# through the webhook
image_size = NotifyImageSize.XY_128
# Our event action type
event_action = 'trigger'
# The default region to use if one isn't otherwise specified
default_region = PagerDutyRegion.US
# Define object templates
templates = (
'{schema}://{integrationkey}@{apikey}',
'{schema}://{integrationkey}@{apikey}/{source}',
'{schema}://{integrationkey}@{apikey}/{source}/{component}',
)
# Define our template tokens
template_tokens = dict(NotifyBase.template_tokens, **{
'apikey': {
'name': _('API Key'),
'type': 'string',
'private': True,
'required': True
},
# Optional but triggers V2 API
'integrationkey': {
'name': _('Routing Key'),
'type': 'string',
'private': True,
'required': True
},
'source': {
# Optional Source Identifier (preferably a FQDN)
'name': _('Source'),
'type': 'string',
'default': 'Apprise',
},
'component': {
# Optional Component Identifier
'name': _('Component'),
'type': 'string',
'default': 'Notification',
},
})
# Define our template arguments
template_args = dict(NotifyBase.template_args, **{
'group': {
'name': _('Group'),
'type': 'string',
},
'class': {
'name': _('Class'),
'type': 'string',
'map_to': 'class_id',
},
'click': {
'name': _('Click'),
'type': 'string',
},
'region': {
'name': _('Region Name'),
'type': 'choice:string',
'values': PAGERDUTY_REGIONS,
'default': PagerDutyRegion.US,
'map_to': 'region_name',
},
'image': {
'name': _('Include Image'),
'type': 'bool',
'default': True,
'map_to': 'include_image',
},
})
# Define any kwargs we're using
template_kwargs = {
'details': {
'name': _('Custom Details'),
'prefix': '+',
},
}
def __init__(self, apikey, integrationkey=None, source=None,
component=None, group=None, class_id=None,
include_image=True, click=None, details=None,
region_name=None, **kwargs):
"""
Initialize Pager Duty Object
"""
super(NotifyPagerDuty, self).__init__(**kwargs)
# Long-Lived Access token (generated from User Profile)
self.apikey = validate_regex(apikey)
if not self.apikey:
msg = 'An invalid Pager Duty API Key ' \
'({}) was specified.'.format(apikey)
self.logger.warning(msg)
raise TypeError(msg)
self.integration_key = validate_regex(integrationkey)
if not self.integration_key:
msg = 'An invalid Pager Duty Routing Key ' \
'({}) was specified.'.format(integrationkey)
self.logger.warning(msg)
raise TypeError(msg)
# An Optional Source
self.source = self.template_tokens['source']['default']
if source:
self.source = validate_regex(source)
if not self.source:
msg = 'An invalid Pager Duty Notification Source ' \
'({}) was specified.'.format(source)
self.logger.warning(msg)
raise TypeError(msg)
else:
self.component = self.template_tokens['source']['default']
# An Optional Component
self.component = self.template_tokens['component']['default']
if component:
self.component = validate_regex(component)
if not self.component:
msg = 'An invalid Pager Duty Notification Component ' \
'({}) was specified.'.format(component)
self.logger.warning(msg)
raise TypeError(msg)
else:
self.component = self.template_tokens['component']['default']
# Store our region
try:
self.region_name = self.default_region \
if region_name is None else region_name.lower()
if self.region_name not in PAGERDUTY_REGIONS:
# allow the outer except to handle this common response
raise
except:
# Invalid region specified
msg = 'The PagerDuty region specified ({}) is invalid.' \
.format(region_name)
self.logger.warning(msg)
raise TypeError(msg)
# A clickthrough option for notifications
self.click = click
# Store Class ID if specified
self.class_id = class_id
# Store Group if specified
self.group = group
self.details = {}
if details:
# Store our extra details
self.details.update(details)
# Display our Apprise Image
self.include_image = include_image
return
def send(self, body, title='', notify_type=NotifyType.INFO, **kwargs):
"""
Send our PagerDuty Notification
"""
# Prepare our headers
headers = {
'User-Agent': self.app_id,
'Content-Type': 'application/json',
'Authorization': 'Token token={}'.format(self.apikey),
}
# Prepare our persistent_notification.create payload
payload = {
# Define our integration key
'routing_key': self.integration_key,
# Prepare our payload
'payload': {
'summary': body,
# Set our severity
'severity': PAGERDUTY_SEVERITY_MAP[notify_type],
# Our Alerting Source/Component
'source': self.source,
'component': self.component,
},
'client': self.app_id,
# Our Event Action
'event_action': self.event_action,
}
if self.group:
payload['payload']['group'] = self.group
if self.class_id:
payload['payload']['class'] = self.class_id
if self.click:
payload['links'] = [{
"href": self.click,
}]
# Acquire our image url if configured to do so
image_url = None if not self.include_image else \
self.image_url(notify_type)
if image_url:
payload['images'] = [{
'src': image_url,
'alt': notify_type,
}]
if self.details:
payload['payload']['custom_details'] = {}
# Apply any provided custom details
for k, v in self.details.items():
payload['payload']['custom_details'][k] = v
# Prepare our URL based on region
notify_url = PAGERDUTY_API_LOOKUP[self.region_name]
self.logger.debug('Pager Duty POST URL: %s (cert_verify=%r)' % (
notify_url, self.verify_certificate,
))
self.logger.debug('Pager Duty Payload: %s' % str(payload))
# Always call throttle before any remote server i/o is made
self.throttle()
try:
r = requests.post(
notify_url,
data=dumps(payload),
headers=headers,
verify=self.verify_certificate,
timeout=self.request_timeout,
)
if r.status_code not in (
requests.codes.ok, requests.codes.created,
requests.codes.accepted):
# We had a problem
status_str = \
NotifyPagerDuty.http_response_code_lookup(
r.status_code)
self.logger.warning(
'Failed to send Pager Duty notification: '
'{}{}error={}.'.format(
status_str,
', ' if status_str else '',
r.status_code))
self.logger.debug('Response Details:\r\n{}'.format(r.content))
# Return; we're done
return False
else:
self.logger.info('Sent Pager Duty notification.')
except requests.RequestException as e:
self.logger.warning(
'A Connection error occurred sending Pager Duty '
'notification to %s.' % self.host)
self.logger.debug('Socket Exception: %s' % str(e))
# Return; we're done
return False
return True
def url(self, privacy=False, *args, **kwargs):
"""
Returns the URL built dynamically based on specified arguments.
"""
# Define any URL parameters
params = {
'region': self.region_name,
'image': 'yes' if self.include_image else 'no',
}
if self.class_id:
params['class'] = self.class_id
if self.group:
params['group'] = self.group
if self.click is not None:
params['click'] = self.click
# Append our custom entries our parameters
params.update({'+{}'.format(k): v for k, v in self.details.items()})
# Extend our parameters
params.update(self.url_parameters(privacy=privacy, *args, **kwargs))
url = '{schema}://{integration_key}@{apikey}/' \
'{source}/{component}?{params}'
return url.format(
schema=self.secure_protocol,
# never encode hostname since we're expecting it to be a valid one
integration_key=self.pprint(
self.integration_key, privacy, mode=PrivacyMode.Secret,
safe=''),
apikey=self.pprint(
self.apikey, privacy, mode=PrivacyMode.Secret, safe=''),
source=self.pprint(
self.source, privacy, safe=''),
component=self.pprint(
self.component, privacy, safe=''),
params=NotifyPagerDuty.urlencode(params),
)
@staticmethod
def parse_url(url):
"""
Parses the URL and returns enough arguments that can allow
us to re-instantiate this object.
"""
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 'apikey' makes it easier to use yaml configuration
if 'apikey' in results['qsd'] and len(results['qsd']['apikey']):
results['apikey'] = \
NotifyPagerDuty.unquote(results['qsd']['apikey'])
else:
results['apikey'] = NotifyPagerDuty.unquote(results['host'])
# The 'integrationkey' makes it easier to use yaml configuration
if 'integrationkey' in results['qsd'] and \
len(results['qsd']['integrationkey']):
results['integrationkey'] = \
NotifyPagerDuty.unquote(results['qsd']['integrationkey'])
else:
results['integrationkey'] = \
NotifyPagerDuty.unquote(results['user'])
if 'click' in results['qsd'] and len(results['qsd']['click']):
results['click'] = NotifyPagerDuty.unquote(results['qsd']['click'])
if 'group' in results['qsd'] and len(results['qsd']['group']):
results['group'] = \
NotifyPagerDuty.unquote(results['qsd']['group'])
if 'class' in results['qsd'] and len(results['qsd']['class']):
results['class_id'] = \
NotifyPagerDuty.unquote(results['qsd']['class'])
# Acquire our full path
fullpath = NotifyPagerDuty.split_path(results['fullpath'])
# Get our source
if 'source' in results['qsd'] and len(results['qsd']['source']):
results['source'] = \
NotifyPagerDuty.unquote(results['qsd']['source'])
else:
results['source'] = fullpath.pop(0) if fullpath else None
# Get our component
if 'component' in results['qsd'] and len(results['qsd']['component']):
results['component'] = \
NotifyPagerDuty.unquote(results['qsd']['component'])
else:
results['component'] = fullpath.pop(0) if fullpath else None
# Add our custom details key/value pairs that the user can potentially
# over-ride if they wish to to our returned result set and tidy
# entries by unquoting them
results['details'] = {
NotifyPagerDuty.unquote(x): NotifyPagerDuty.unquote(y)
for x, y in results['qsd+'].items()}
if 'region' in results['qsd'] and len(results['qsd']['region']):
# Extract from name to associate with from address
results['region_name'] = \
NotifyPagerDuty.unquote(results['qsd']['region'])
# Include images with our message
results['include_image'] = \
parse_bool(results['qsd'].get('image', True))
return results

View file

@ -26,7 +26,6 @@
# Official API reference: https://developer.gitter.im/docs/user-resource # Official API reference: https://developer.gitter.im/docs/user-resource
import re import re
import six
import requests import requests
from json import dumps from json import dumps
@ -40,7 +39,7 @@ TARGET_LIST_DELIM = re.compile(r'[ \t\r\n,\\/]+')
# Priorities # Priorities
class ParsePlatformDevice(object): class ParsePlatformDevice:
# All Devices # All Devices
ALL = 'all' ALL = 'all'
@ -134,7 +133,7 @@ class NotifyParsePlatform(NotifyBase):
super(NotifyParsePlatform, self).__init__(**kwargs) super(NotifyParsePlatform, self).__init__(**kwargs)
self.fullpath = kwargs.get('fullpath') self.fullpath = kwargs.get('fullpath')
if not isinstance(self.fullpath, six.string_types): if not isinstance(self.fullpath, str):
self.fullpath = '/' self.fullpath = '/'
# Application ID # Application ID

View file

@ -32,7 +32,7 @@ from ..AppriseLocale import gettext_lazy as _
# Priorities # Priorities
class ProwlPriority(object): class ProwlPriority:
LOW = -2 LOW = -2
MODERATE = -1 MODERATE = -1
NORMAL = 0 NORMAL = 0
@ -40,13 +40,34 @@ class ProwlPriority(object):
EMERGENCY = 2 EMERGENCY = 2
PROWL_PRIORITIES = ( PROWL_PRIORITIES = {
ProwlPriority.LOW, # Note: This also acts as a reverse lookup mapping
ProwlPriority.MODERATE, ProwlPriority.LOW: 'low',
ProwlPriority.NORMAL, ProwlPriority.MODERATE: 'moderate',
ProwlPriority.HIGH, ProwlPriority.NORMAL: 'normal',
ProwlPriority.EMERGENCY, ProwlPriority.HIGH: 'high',
) ProwlPriority.EMERGENCY: 'emergency',
}
PROWL_PRIORITY_MAP = {
# Maps against string 'low'
'l': ProwlPriority.LOW,
# Maps against string 'moderate'
'm': ProwlPriority.MODERATE,
# Maps against string 'normal'
'n': ProwlPriority.NORMAL,
# Maps against string 'high'
'h': ProwlPriority.HIGH,
# Maps against string 'emergency'
'e': ProwlPriority.EMERGENCY,
# Entries to additionally support (so more like Prowl's API)
'-2': ProwlPriority.LOW,
'-1': ProwlPriority.MODERATE,
'0': ProwlPriority.NORMAL,
'1': ProwlPriority.HIGH,
'2': ProwlPriority.EMERGENCY,
}
# Provide some known codes Prowl uses and what they translate to: # Provide some known codes Prowl uses and what they translate to:
PROWL_HTTP_ERROR_MAP = { PROWL_HTTP_ERROR_MAP = {
@ -124,11 +145,13 @@ class NotifyProwl(NotifyBase):
""" """
super(NotifyProwl, self).__init__(**kwargs) super(NotifyProwl, self).__init__(**kwargs)
if priority not in PROWL_PRIORITIES: # The Priority of the message
self.priority = self.template_args['priority']['default'] self.priority = NotifyProwl.template_args['priority']['default'] \
if not priority else \
else: next((
self.priority = priority v for k, v in PROWL_PRIORITY_MAP.items()
if str(priority).lower().startswith(k)),
NotifyProwl.template_args['priority']['default'])
# API Key (associated with project) # API Key (associated with project)
self.apikey = validate_regex( self.apikey = validate_regex(
@ -229,18 +252,12 @@ class NotifyProwl(NotifyBase):
Returns the URL built dynamically based on specified arguments. Returns the URL built dynamically based on specified arguments.
""" """
_map = {
ProwlPriority.LOW: 'low',
ProwlPriority.MODERATE: 'moderate',
ProwlPriority.NORMAL: 'normal',
ProwlPriority.HIGH: 'high',
ProwlPriority.EMERGENCY: 'emergency',
}
# Define any URL parameters # Define any URL parameters
params = { params = {
'priority': 'normal' if self.priority not in _map 'priority':
else _map[self.priority], PROWL_PRIORITIES[self.template_args['priority']['default']]
if self.priority not in PROWL_PRIORITIES
else PROWL_PRIORITIES[self.priority],
} }
# Extend our parameters # Extend our parameters
@ -276,32 +293,9 @@ class NotifyProwl(NotifyBase):
except IndexError: except IndexError:
pass pass
# Set our priority
if 'priority' in results['qsd'] and len(results['qsd']['priority']): if 'priority' in results['qsd'] and len(results['qsd']['priority']):
_map = { results['priority'] = \
# Letter Assignments NotifyProwl.unquote(results['qsd']['priority'])
'l': ProwlPriority.LOW,
'm': ProwlPriority.MODERATE,
'n': ProwlPriority.NORMAL,
'h': ProwlPriority.HIGH,
'e': ProwlPriority.EMERGENCY,
'lo': ProwlPriority.LOW,
'me': ProwlPriority.MODERATE,
'no': ProwlPriority.NORMAL,
'hi': ProwlPriority.HIGH,
'em': ProwlPriority.EMERGENCY,
# Support 3rd Party Documented Scale
'-2': ProwlPriority.LOW,
'-1': ProwlPriority.MODERATE,
'0': ProwlPriority.NORMAL,
'1': ProwlPriority.HIGH,
'2': ProwlPriority.EMERGENCY,
}
try:
results['priority'] = \
_map[results['qsd']['priority'][0:2].lower()]
except KeyError:
# No priority was set
pass
return results return results

View file

@ -23,8 +23,6 @@
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
# THE SOFTWARE. # THE SOFTWARE.
# We use io because it allows us to test the open() call
import io
import base64 import base64
import requests import requests
from json import loads from json import loads
@ -36,7 +34,7 @@ from ..utils import validate_regex
from ..AppriseLocale import gettext_lazy as _ from ..AppriseLocale import gettext_lazy as _
class PushSaferSound(object): class PushSaferSound:
""" """
Defines all of the supported PushSafe sounds Defines all of the supported PushSafe sounds
""" """
@ -248,7 +246,7 @@ PUSHSAFER_SOUND_MAP = {
# Priorities # Priorities
class PushSaferPriority(object): class PushSaferPriority:
LOW = -2 LOW = -2
MODERATE = -1 MODERATE = -1
NORMAL = 0 NORMAL = 0
@ -282,7 +280,7 @@ DEFAULT_PRIORITY = "normal"
# Vibrations # Vibrations
class PushSaferVibration(object): class PushSaferVibration:
""" """
Defines the acceptable vibration settings for notification Defines the acceptable vibration settings for notification
""" """
@ -565,7 +563,7 @@ class NotifyPushSafer(NotifyBase):
attachment.url(privacy=True))) attachment.url(privacy=True)))
try: try:
with io.open(attachment.path, 'rb') as f: with open(attachment.path, 'rb') as f:
# Output must be in a DataURL format (that's what # Output must be in a DataURL format (that's what
# PushSafer calls it): # PushSafer calls it):
attachment = ( attachment = (

View file

@ -24,12 +24,12 @@
# THE SOFTWARE. # THE SOFTWARE.
import re import re
import six
import requests import requests
from .NotifyBase import NotifyBase from .NotifyBase import NotifyBase
from ..common import NotifyType from ..common import NotifyType
from ..common import NotifyFormat from ..common import NotifyFormat
from ..conversion import convert_between
from ..utils import parse_list from ..utils import parse_list
from ..utils import validate_regex from ..utils import validate_regex
from ..AppriseLocale import gettext_lazy as _ from ..AppriseLocale import gettext_lazy as _
@ -43,7 +43,7 @@ VALIDATE_DEVICE = re.compile(r'^[a-z0-9_]{1,25}$', re.I)
# Priorities # Priorities
class PushoverPriority(object): class PushoverPriority:
LOW = -2 LOW = -2
MODERATE = -1 MODERATE = -1
NORMAL = 0 NORMAL = 0
@ -52,7 +52,7 @@ class PushoverPriority(object):
# Sounds # Sounds
class PushoverSound(object): class PushoverSound:
PUSHOVER = 'pushover' PUSHOVER = 'pushover'
BIKE = 'bike' BIKE = 'bike'
BUGLE = 'bugle' BUGLE = 'bugle'
@ -102,13 +102,34 @@ PUSHOVER_SOUNDS = (
PushoverSound.NONE, PushoverSound.NONE,
) )
PUSHOVER_PRIORITIES = ( PUSHOVER_PRIORITIES = {
PushoverPriority.LOW, # Note: This also acts as a reverse lookup mapping
PushoverPriority.MODERATE, PushoverPriority.LOW: 'low',
PushoverPriority.NORMAL, PushoverPriority.MODERATE: 'moderate',
PushoverPriority.HIGH, PushoverPriority.NORMAL: 'normal',
PushoverPriority.EMERGENCY, PushoverPriority.HIGH: 'high',
) PushoverPriority.EMERGENCY: 'emergency',
}
PUSHOVER_PRIORITY_MAP = {
# Maps against string 'low'
'l': PushoverPriority.LOW,
# Maps against string 'moderate'
'm': PushoverPriority.MODERATE,
# Maps against string 'normal'
'n': PushoverPriority.NORMAL,
# Maps against string 'high'
'h': PushoverPriority.HIGH,
# Maps against string 'emergency'
'e': PushoverPriority.EMERGENCY,
# Entries to additionally support (so more like Pushover's API)
'-2': PushoverPriority.LOW,
'-1': PushoverPriority.MODERATE,
'0': PushoverPriority.NORMAL,
'1': PushoverPriority.HIGH,
'2': PushoverPriority.EMERGENCY,
}
# Extend HTTP Error Messages # Extend HTTP Error Messages
PUSHOVER_HTTP_ERROR_MAP = { PUSHOVER_HTTP_ERROR_MAP = {
@ -258,18 +279,20 @@ class NotifyPushover(NotifyBase):
# Setup our sound # Setup our sound
self.sound = NotifyPushover.default_pushover_sound \ self.sound = NotifyPushover.default_pushover_sound \
if not isinstance(sound, six.string_types) else sound.lower() if not isinstance(sound, str) else sound.lower()
if self.sound and self.sound not in PUSHOVER_SOUNDS: if self.sound and self.sound not in PUSHOVER_SOUNDS:
msg = 'The sound specified ({}) is invalid.'.format(sound) msg = 'The sound specified ({}) is invalid.'.format(sound)
self.logger.warning(msg) self.logger.warning(msg)
raise TypeError(msg) raise TypeError(msg)
# The Priority of the message # The Priority of the message
if priority not in PUSHOVER_PRIORITIES: self.priority = int(
self.priority = self.template_args['priority']['default'] NotifyPushover.template_args['priority']['default']
if priority is None else
else: next((
self.priority = priority v for k, v in PUSHOVER_PRIORITY_MAP.items()
if str(priority).lower().startswith(k)),
NotifyPushover.template_args['priority']['default']))
# The following are for emergency alerts # The following are for emergency alerts
if self.priority == PushoverPriority.EMERGENCY: if self.priority == PushoverPriority.EMERGENCY:
@ -344,6 +367,10 @@ class NotifyPushover(NotifyBase):
if self.notify_format == NotifyFormat.HTML: if self.notify_format == NotifyFormat.HTML:
# https://pushover.net/api#html # https://pushover.net/api#html
payload['html'] = 1 payload['html'] = 1
elif self.notify_format == NotifyFormat.MARKDOWN:
payload['message'] = convert_between(
NotifyFormat.MARKDOWN, NotifyFormat.HTML, body)
payload['html'] = 1
if self.priority == PushoverPriority.EMERGENCY: if self.priority == PushoverPriority.EMERGENCY:
payload.update({'retry': self.retry, 'expire': self.expire}) payload.update({'retry': self.retry, 'expire': self.expire})
@ -404,24 +431,25 @@ class NotifyPushover(NotifyBase):
attach.mimetype, attach.mimetype,
attach.url(privacy=True))) attach.url(privacy=True)))
return True attach = None
# If we get here, we're dealing with a supported image. else:
# Verify that the filesize is okay though. # If we get here, we're dealing with a supported image.
file_size = len(attach) # Verify that the filesize is okay though.
if not (file_size > 0 file_size = len(attach)
and file_size <= self.attach_max_size_bytes): if not (file_size > 0
and file_size <= self.attach_max_size_bytes):
# File size is no good # File size is no good
self.logger.warning( self.logger.warning(
'Pushover attachment size ({}B) exceeds limit: {}' 'Pushover attachment size ({}B) exceeds limit: {}'
.format(file_size, attach.url(privacy=True))) .format(file_size, attach.url(privacy=True)))
return False return False
self.logger.debug( self.logger.debug(
'Posting Pushover attachment {}'.format( 'Posting Pushover attachment {}'.format(
attach.url(privacy=True))) attach.url(privacy=True)))
# Default Header # Default Header
headers = { headers = {
@ -510,19 +538,12 @@ class NotifyPushover(NotifyBase):
Returns the URL built dynamically based on specified arguments. Returns the URL built dynamically based on specified arguments.
""" """
_map = {
PushoverPriority.LOW: 'low',
PushoverPriority.MODERATE: 'moderate',
PushoverPriority.NORMAL: 'normal',
PushoverPriority.HIGH: 'high',
PushoverPriority.EMERGENCY: 'emergency',
}
# Define any URL parameters # Define any URL parameters
params = { params = {
'priority': 'priority':
_map[self.template_args['priority']['default']] PUSHOVER_PRIORITIES[self.template_args['priority']['default']]
if self.priority not in _map else _map[self.priority], if self.priority not in PUSHOVER_PRIORITIES
else PUSHOVER_PRIORITIES[self.priority],
} }
# Only add expire and retry for emergency messages, # Only add expire and retry for emergency messages,
@ -563,20 +584,8 @@ class NotifyPushover(NotifyBase):
# Set our priority # Set our priority
if 'priority' in results['qsd'] and len(results['qsd']['priority']): if 'priority' in results['qsd'] and len(results['qsd']['priority']):
_map = { results['priority'] = \
'l': PushoverPriority.LOW, NotifyPushover.unquote(results['qsd']['priority'])
'm': PushoverPriority.MODERATE,
'n': PushoverPriority.NORMAL,
'h': PushoverPriority.HIGH,
'e': PushoverPriority.EMERGENCY,
}
try:
results['priority'] = \
_map[results['qsd']['priority'][0].lower()]
except KeyError:
# No priority was set
pass
# Retrieve all of our targets # Retrieve all of our targets
results['targets'] = NotifyPushover.split_path(results['fullpath']) results['targets'] = NotifyPushover.split_path(results['fullpath'])

View file

@ -44,7 +44,6 @@
# - https://www.reddit.com/dev/api/ # - https://www.reddit.com/dev/api/
# - https://www.reddit.com/dev/api/#POST_api_submit # - https://www.reddit.com/dev/api/#POST_api_submit
# - https://github.com/reddit-archive/reddit/wiki/API # - https://github.com/reddit-archive/reddit/wiki/API
import six
import requests import requests
from json import loads from json import loads
from datetime import timedelta from datetime import timedelta
@ -66,7 +65,7 @@ REDDIT_HTTP_ERROR_MAP = {
} }
class RedditMessageKind(object): class RedditMessageKind:
""" """
Define the kinds of messages supported Define the kinds of messages supported
""" """
@ -271,7 +270,7 @@ class NotifyReddit(NotifyBase):
self.__access_token_expiry = datetime.utcnow() self.__access_token_expiry = datetime.utcnow()
self.kind = kind.strip().lower() \ self.kind = kind.strip().lower() \
if isinstance(kind, six.string_types) \ if isinstance(kind, str) \
else self.template_args['kind']['default'] else self.template_args['kind']['default']
if self.kind not in REDDIT_MESSAGE_KINDS: if self.kind not in REDDIT_MESSAGE_KINDS:

View file

@ -24,7 +24,6 @@
# THE SOFTWARE. # THE SOFTWARE.
import re import re
import six
import requests import requests
from json import loads from json import loads
from json import dumps from json import dumps
@ -54,7 +53,7 @@ RC_HTTP_ERROR_MAP = {
LIST_DELIM = re.compile(r'[ \t\r\n,\\/]+') LIST_DELIM = re.compile(r'[ \t\r\n,\\/]+')
class RocketChatAuthMode(object): class RocketChatAuthMode:
""" """
The Chat Authentication mode is detected The Chat Authentication mode is detected
""" """
@ -218,7 +217,7 @@ class NotifyRocketChat(NotifyBase):
# Authentication mode # Authentication mode
self.mode = None \ self.mode = None \
if not isinstance(mode, six.string_types) \ if not isinstance(mode, str) \
else mode.lower() else mode.lower()
if self.mode and self.mode not in ROCKETCHAT_AUTH_MODES: if self.mode and self.mode not in ROCKETCHAT_AUTH_MODES:

View file

@ -32,7 +32,6 @@
# These are important <---^----------------------------------------^ # These are important <---^----------------------------------------^
# #
import re import re
import six
import requests import requests
from json import dumps from json import dumps
@ -44,7 +43,7 @@ from ..utils import validate_regex
from ..AppriseLocale import gettext_lazy as _ from ..AppriseLocale import gettext_lazy as _
class RyverWebhookMode(object): class RyverWebhookMode:
""" """
Ryver supports to webhook modes Ryver supports to webhook modes
""" """
@ -152,7 +151,7 @@ class NotifyRyver(NotifyBase):
# Store our webhook mode # Store our webhook mode
self.mode = None \ self.mode = None \
if not isinstance(mode, six.string_types) else mode.lower() if not isinstance(mode, str) else mode.lower()
if self.mode not in RYVER_WEBHOOK_MODES: if self.mode not in RYVER_WEBHOOK_MODES:
msg = 'The Ryver webhook mode specified ({}) is invalid.' \ msg = 'The Ryver webhook mode specified ({}) is invalid.' \

View file

@ -89,13 +89,7 @@ from email.mime.application import MIMEApplication
from email.mime.multipart import MIMEMultipart from email.mime.multipart import MIMEMultipart
from email.utils import formataddr from email.utils import formataddr
from email.header import Header from email.header import Header
try: from urllib.parse import quote
# Python v3.x
from urllib.parse import quote
except ImportError:
# Python v2.x
from urllib import quote
from .NotifyBase import NotifyBase from .NotifyBase import NotifyBase
from ..URLBase import PrivacyMode from ..URLBase import PrivacyMode
@ -395,26 +389,15 @@ class NotifySES(NotifyBase):
# Strip target out of bcc list if in To # Strip target out of bcc list if in To
bcc = (self.bcc - set([to_addr])) bcc = (self.bcc - set([to_addr]))
try: # Format our cc addresses to support the Name field
# Format our cc addresses to support the Name field cc = [formataddr(
cc = [formataddr( (self.names.get(addr, False), addr), charset='utf-8')
(self.names.get(addr, False), addr), charset='utf-8') for addr in cc]
for addr in cc]
# Format our bcc addresses to support the Name field # Format our bcc addresses to support the Name field
bcc = [formataddr( bcc = [formataddr(
(self.names.get(addr, False), addr), charset='utf-8') (self.names.get(addr, False), addr), charset='utf-8')
for addr in bcc] for addr in bcc]
except TypeError:
# Python v2.x Support (no charset keyword)
# Format our cc addresses to support the Name field
cc = [formataddr( # pragma: no branch
(self.names.get(addr, False), addr)) for addr in cc]
# Format our bcc addresses to support the Name field
bcc = [formataddr( # pragma: no branch
(self.names.get(addr, False), addr)) for addr in bcc]
self.logger.debug('Email From: {} <{}>'.format( self.logger.debug('Email From: {} <{}>'.format(
quote(reply_to[0], ' '), quote(reply_to[0], ' '),
@ -436,23 +419,14 @@ class NotifySES(NotifyBase):
# Create a Multipart container if there is an attachment # Create a Multipart container if there is an attachment
base = MIMEMultipart() if attach else content base = MIMEMultipart() if attach else content
# TODO: Deduplicate with `NotifyEmail`?
base['Subject'] = Header(title, 'utf-8') base['Subject'] = Header(title, 'utf-8')
try: base['From'] = formataddr(
base['From'] = formataddr( (from_name if from_name else False, self.from_addr),
(from_name if from_name else False, self.from_addr), charset='utf-8')
charset='utf-8') base['To'] = formataddr((to_name, to_addr), charset='utf-8')
base['To'] = formataddr((to_name, to_addr), charset='utf-8') if reply_to[1] != self.from_addr:
if reply_to[1] != self.from_addr: base['Reply-To'] = formataddr(reply_to, charset='utf-8')
base['Reply-To'] = formataddr(reply_to, charset='utf-8')
except TypeError:
# Python v2.x Support (no charset keyword)
base['From'] = formataddr(
(from_name if from_name else False, self.from_addr))
base['To'] = formataddr((to_name, to_addr))
if reply_to[1] != self.from_addr:
base['Reply-To'] = formataddr(reply_to)
base['Cc'] = ','.join(cc) base['Cc'] = ','.join(cc)
base['Date'] = \ base['Date'] = \
datetime.utcnow().strftime("%a, %d %b %Y %H:%M:%S +0000") datetime.utcnow().strftime("%a, %d %b %Y %H:%M:%S +0000")

View file

@ -0,0 +1,640 @@
# -*- coding: utf-8 -*-
#
# Copyright (C) 2022 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
import requests
from json import dumps, loads
import base64
from itertools import chain
from .NotifyBase import NotifyBase
from ..common import NotifyType
from ..utils import validate_regex
from ..utils import is_phone_no
from ..utils import parse_phone_no
from ..utils import parse_bool
from ..URLBase import PrivacyMode
from ..AppriseLocale import gettext_lazy as _
GROUP_REGEX = re.compile(
r'^\s*(\#|\%35)(?P<group>[a-z0-9_-]+)', re.I)
CONTACT_REGEX = re.compile(
r'^\s*(\@|\%40)?(?P<contact>[a-z0-9_-]+)', re.I)
# Priorities
class SMSEaglePriority:
NORMAL = 0
HIGH = 1
SMSEAGLE_PRIORITIES = (
SMSEaglePriority.NORMAL,
SMSEaglePriority.HIGH,
)
SMSEAGLE_PRIORITY_MAP = {
# short for 'normal'
'normal': SMSEaglePriority.NORMAL,
# short for 'high'
'+': SMSEaglePriority.HIGH,
'high': SMSEaglePriority.HIGH,
}
class NotifySMSEagle(NotifyBase):
"""
A wrapper for SMSEagle Notifications
"""
# The default descriptive name associated with the Notification
service_name = 'SMS Eagle'
# The services URL
service_url = 'https://smseagle.eu'
# The default protocol
protocol = 'smseagle'
# The default protocol
secure_protocol = 'smseagles'
# A URL that takes you to the setup/help of the specific protocol
setup_url = 'https://github.com/caronc/apprise/wiki/Notify_smseagle'
# The path we send our notification to
notify_path = '/jsonrpc/sms'
# The maxumum length of the text message
# The actual limit is 160 but SMSEagle looks after the handling
# of large messages in it's upstream service
body_maxlen = 1200
# The maximum targets to include when doing batch transfers
default_batch_size = 10
# We don't support titles for SMSEagle notifications
title_maxlen = 0
# Define object templates
templates = (
'{schema}://{token}@{host}/{targets}',
'{schema}://{token}@{host}:{port}/{targets}',
)
# Define our template tokens
template_tokens = dict(NotifyBase.template_tokens, **{
'host': {
'name': _('Hostname'),
'type': 'string',
'required': True,
},
'port': {
'name': _('Port'),
'type': 'int',
'min': 1,
'max': 65535,
},
'token': {
'name': _('Access Token'),
'type': 'string',
},
'target_phone': {
'name': _('Target Phone No'),
'type': 'string',
'prefix': '+',
'regex': (r'^[0-9\s)(+-]+$', 'i'),
'map_to': 'targets',
},
'target_group': {
'name': _('Target Group ID'),
'type': 'string',
'prefix': '#',
'regex': (r'^[a-z0-9_-]+$', 'i'),
'map_to': 'targets',
},
'target_contact': {
'name': _('Target Contact'),
'type': 'string',
'prefix': '@',
'regex': (r'^[a-z0-9_-]+$', 'i'),
'map_to': 'targets',
},
'targets': {
'name': _('Targets'),
'type': 'list:string',
}
})
# Define our template arguments
template_args = dict(NotifyBase.template_args, **{
'to': {
'alias_of': 'targets',
},
'token': {
'alias_of': 'token',
},
'batch': {
'name': _('Batch Mode'),
'type': 'bool',
'default': False,
},
'status': {
'name': _('Show Status'),
'type': 'bool',
'default': False,
},
'test': {
'name': _('Test Only'),
'type': 'bool',
'default': False,
},
'flash': {
'name': _('Flash'),
'type': 'bool',
'default': False,
},
'priority': {
'name': _('Priority'),
'type': 'choice:int',
'values': SMSEAGLE_PRIORITIES,
'default': SMSEaglePriority.NORMAL,
},
})
def __init__(self, token=None, targets=None, priority=None, batch=False,
status=False, flash=False, test=False, **kwargs):
"""
Initialize SMSEagle Object
"""
super(NotifySMSEagle, self).__init__(**kwargs)
# Prepare Flash Mode Flag
self.flash = flash
# Prepare Test Mode Flag
self.test = test
# Prepare Batch Mode Flag
self.batch = batch
# Set Status type
self.status = status
# Parse our targets
self.target_phones = list()
self.target_groups = list()
self.target_contacts = list()
# Used for URL generation afterwards only
self.invalid_targets = list()
# We always use a token if provided
self.token = validate_regex(self.user if not token else token)
if not self.token:
msg = \
'An invalid SMSEagle Access Token ({}) was specified.'.format(
self.user if not token else token)
self.logger.warning(msg)
raise TypeError(msg)
#
# Priority
#
try:
# Acquire our priority if we can:
# - We accept both the integer form as well as a string
# representation
self.priority = int(priority)
except TypeError:
# NoneType means use Default; this is an okay exception
self.priority = self.template_args['priority']['default']
except ValueError:
# Input is a string; attempt to get the lookup from our
# priority mapping
priority = priority.lower().strip()
# This little bit of black magic allows us to match against
# low, lo, l (for low);
# normal, norma, norm, nor, no, n (for normal)
# ... etc
result = next((key for key in SMSEAGLE_PRIORITY_MAP.keys()
if key.startswith(priority)), None) \
if priority else None
# Now test to see if we got a match
if not result:
msg = 'An invalid SMSEagle priority ' \
'({}) was specified.'.format(priority)
self.logger.warning(msg)
raise TypeError(msg)
# store our successfully looked up priority
self.priority = SMSEAGLE_PRIORITY_MAP[result]
if self.priority is not None and \
self.priority not in SMSEAGLE_PRIORITY_MAP.values():
msg = 'An invalid SMSEagle priority ' \
'({}) was specified.'.format(priority)
self.logger.warning(msg)
raise TypeError(msg)
# Validate our targerts
for target in parse_phone_no(targets):
# Validate targets and drop bad ones:
# Allow 9 digit numbers (without country code)
result = is_phone_no(target, min_len=9)
if result:
# store valid phone number
self.target_phones.append(
'{}{}'.format(
'' if target[0] != '+' else '+', result['full']))
continue
result = GROUP_REGEX.match(target)
if result:
# Just store group information
self.target_groups.append(result.group('group'))
continue
result = CONTACT_REGEX.match(target)
if result:
# Just store contact information
self.target_contacts.append(result.group('contact'))
continue
self.logger.warning(
'Dropped invalid phone/group/contact '
'({}) specified.'.format(target),
)
self.invalid_targets.append(target)
continue
return
def send(self, body, title='', notify_type=NotifyType.INFO, attach=None,
**kwargs):
"""
Perform SMSEagle Notification
"""
if not self.target_groups and not self.target_phones \
and not self.target_contacts:
# There were no services to notify
self.logger.warning(
'There were no SMSEagle targets to notify.')
return False
# error tracking (used for function return)
has_error = False
attachments = []
if attach:
for attachment in attach:
# Perform some simple error checking
if not attachment:
# We could not access the attachment
self.logger.error(
'Could not access attachment {}.'.format(
attachment.url(privacy=True)))
return False
if not re.match(r'^image/.*', attachment.mimetype, re.I):
# Only support images at this time
self.logger.warning(
'Ignoring unsupported SMSEagle attachment {}.'.format(
attachment.url(privacy=True)))
continue
try:
with open(attachment.path, 'rb') as f:
# Prepare our Attachment in Base64
attachments.append({
'content_type': attachment.mimetype,
'content': base64.b64encode(
f.read()).decode('utf-8'),
})
except (OSError, IOError) as e:
self.logger.warning(
'An I/O error occurred while reading {}.'.format(
attachment.name if attachment else 'attachment'))
self.logger.debug('I/O Exception: %s' % str(e))
return False
# Prepare our headers
headers = {
'User-Agent': self.app_id,
'Content-Type': 'application/json',
}
# Prepare our payload
params_template = {
# Our Access Token
'access_token': self.token,
# The message to send (populated below)
"message": None,
# 0 = normal priority, 1 = high priority
"highpriority": self.priority,
# Support unicode characters
"unicode": 1,
# sms or mms (if attachment)
"message_type": 'sms',
# Response Types:
# simple: format response as simple object with one result field
# extended: format response as extended JSON object
"responsetype": 'extended',
# SMS will be sent as flash message (1 = yes, 0 = no)
"flash": 1 if self.flash else 0,
# Message Simulation
"test": 1 if self.test else 0,
}
# Set our schema
schema = 'https' if self.secure else 'http'
# Construct our URL
notify_url = '%s://%s' % (schema, self.host)
if isinstance(self.port, int):
notify_url += ':%d' % self.port
notify_url += self.notify_path
# Send in batches if identified to do so
batch_size = 1 if not self.batch else self.default_batch_size
notify_by = {
'phone': {
"method": "sms.send_sms",
'target': 'to',
},
'group': {
"method": "sms.send_togroup",
'target': 'groupname',
},
'contact': {
"method": "sms.send_tocontact",
'target': 'contactname',
},
}
# categories separated into a tuple since notify_by.keys()
# returns an unpredicable list in Python 2.7 which causes
# tests to fail every so often
for category in ('phone', 'group', 'contact'):
# Create a copy of our template
payload = {
'method': notify_by[category]['method'],
'params': {
notify_by[category]['target']: None,
},
}
# Apply Template
payload['params'].update(params_template)
# Set our Message
payload["params"]["message"] = "{}{}".format(
'' if not self.status else '{} '.format(
self.asset.ascii(notify_type)), body)
if attachments:
# Store our attachments
payload['params']['message_type'] = 'mms'
payload['params']['attachments'] = attachments
targets = getattr(self, 'target_{}s'.format(category))
for index in range(0, len(targets), batch_size):
# Prepare our recipients
payload['params'][notify_by[category]['target']] = \
','.join(targets[index:index + batch_size])
self.logger.debug('SMSEagle POST URL: %s (cert_verify=%r)' % (
notify_url, self.verify_certificate,
))
self.logger.debug('SMSEagle Payload: %s' % str(payload))
# Always call throttle before any remote server i/o is made
self.throttle()
try:
r = requests.post(
notify_url,
data=dumps(payload),
headers=headers,
verify=self.verify_certificate,
timeout=self.request_timeout,
)
try:
content = loads(r.content)
# Store our status
status_str = str(content['result'])
except (AttributeError, TypeError, ValueError, KeyError):
# ValueError = r.content is Unparsable
# TypeError = r.content is None
# AttributeError = r is None
# KeyError = 'result' is not found in result
content = {}
# The result set can be a list such as:
# b'{"result":[{"message_id":4753,"status":"ok"}]}'
#
# It can also just be as a dictionary:
# b'{"result":{"message_id":4753,"status":"ok"}}'
#
# The below code handles both cases only only fails if a
# non-ok value was returned
if r.status_code not in (
requests.codes.ok, requests.codes.created) or \
not isinstance(content.get('result'),
(dict, list)) or \
(isinstance(content.get('result'), dict) and
content['result'].get('status') != 'ok') or \
(isinstance(content.get('result'), list) and
next((True for entry in content.get('result')
if isinstance(entry, dict) and
entry.get('status') != 'ok'), False
) # pragma: no cover
):
# We had a problem
status_str = content.get('result') \
if content.get('result') else \
NotifySMSEagle.http_response_code_lookup(
r.status_code)
self.logger.warning(
'Failed to send {} {} SMSEagle {} notification: '
'{}{}error={}.'.format(
len(targets[index:index + batch_size]),
'to {}'.format(targets[index])
if batch_size == 1 else '(s)',
category,
status_str,
', ' if status_str else '',
r.status_code))
self.logger.debug(
'Response {} Details:\r\n{}'.format(
category.upper(), r.content))
# Mark our failure
has_error = True
continue
else:
self.logger.info(
'Sent {} SMSEagle {} notification{}.'
.format(
len(targets[index:index + batch_size]),
category,
' to {}'.format(targets[index])
if batch_size == 1 else '(s)',
))
except requests.RequestException as e:
self.logger.warning(
'A Connection error occured sending {} SMSEagle '
'{} notification(s).'.format(
len(targets[index:index + batch_size]), category))
self.logger.debug('Socket Exception: %s' % str(e))
# Mark our failure
has_error = True
continue
return not has_error
def url(self, privacy=False, *args, **kwargs):
"""
Returns the URL built dynamically based on specified arguments.
"""
# Define any URL parameters
params = {
'batch': 'yes' if self.batch else 'no',
'status': 'yes' if self.status else 'no',
'flash': 'yes' if self.flash else 'no',
'test': 'yes' if self.test else 'no',
}
# Extend our parameters
params.update(self.url_parameters(privacy=privacy, *args, **kwargs))
default_priority = self.template_args['priority']['default']
if self.priority is not None:
# Store our priority; but only if it was specified
params['priority'] = \
next((key for key, value in SMSEAGLE_PRIORITY_MAP.items()
if value == self.priority),
default_priority) # pragma: no cover
# Default port handling
default_port = 443 if self.secure else 80
return '{schema}://{token}@{hostname}{port}/{targets}?{params}'.format(
schema=self.secure_protocol if self.secure else self.protocol,
token=self.pprint(
self.token, privacy, mode=PrivacyMode.Secret, safe=''),
# never encode hostname since we're expecting it to be a valid one
hostname=self.host,
port='' if self.port is None or self.port == default_port
else ':{}'.format(self.port),
targets='/'.join(
[NotifySMSEagle.quote(x, safe='#@') for x in chain(
# Pass phones directly as is
self.target_phones,
# Contacts
['@{}'.format(x) for x in self.target_contacts],
# Groups
['#{}'.format(x) for x in self.target_groups],
)]),
params=NotifySMSEagle.urlencode(params),
)
@staticmethod
def parse_url(url):
"""
Parses the URL and returns enough arguments that can allow
us to re-instantiate this object.
"""
results = NotifyBase.parse_url(url, verify_host=False)
if not results:
# We're done early as we couldn't load the results
return results
# Get our entries; split_path() looks after unquoting content for us
# by default
results['targets'] = \
NotifySMSEagle.split_path(results['fullpath'])
if 'token' in results['qsd'] and len(results['qsd']['token']):
results['token'] = NotifySMSEagle.unquote(results['qsd']['token'])
elif not results['password'] and results['user']:
results['token'] = NotifySMSEagle.unquote(results['user'])
# Support the 'to' variable so that we can support targets this way too
# The 'to' makes it easier to use yaml configuration
if 'to' in results['qsd'] and len(results['qsd']['to']):
results['targets'] += \
NotifySMSEagle.parse_phone_no(results['qsd']['to'])
# Get Batch Mode Flag
results['batch'] = \
parse_bool(results['qsd'].get('batch', False))
# Get Flash Mode Flag
results['flash'] = \
parse_bool(results['qsd'].get('flash', False))
# Get Test Mode Flag
results['test'] = \
parse_bool(results['qsd'].get('test', False))
# Get status switch
results['status'] = \
parse_bool(results['qsd'].get('status', False))
if 'priority' in results['qsd'] and len(results['qsd']['priority']):
results['priority'] = \
NotifySMSEagle.unquote(results['qsd']['priority'])
return results

View file

@ -315,17 +315,9 @@ class NotifySMTP2Go(NotifyBase):
self.logger.debug('I/O Exception: %s' % str(e)) self.logger.debug('I/O Exception: %s' % str(e))
return False return False
try: sender = formataddr(
sender = formataddr( (self.from_name if self.from_name else False,
(self.from_name if self.from_name else False, self.from_addr), charset='utf-8')
self.from_addr), charset='utf-8')
except TypeError:
# Python v2.x Support (no charset keyword)
# Format our cc addresses to support the Name field
sender = formataddr(
(self.from_name if self.from_name else False,
self.from_addr))
# Prepare our payload # Prepare our payload
payload = { payload = {
@ -369,33 +361,17 @@ class NotifySMTP2Go(NotifyBase):
# Strip target out of bcc list if in To # Strip target out of bcc list if in To
bcc = (bcc - set([to_addr[1]])) bcc = (bcc - set([to_addr[1]]))
try: # Prepare our `to`
# Prepare our to to.append(formataddr(to_addr, charset='utf-8'))
to.append(formataddr(to_addr, charset='utf-8'))
except TypeError:
# Python v2.x Support (no charset keyword)
# Format our cc addresses to support the Name field
# Prepare our to
to.append(formataddr(to_addr))
# Prepare our To # Prepare our To
payload['to'] = to payload['to'] = to
if cc: if cc:
try: # Format our cc addresses to support the Name field
# Format our cc addresses to support the Name field payload['cc'] = [formataddr(
payload['cc'] = [formataddr( (self.names.get(addr, False), addr), charset='utf-8')
(self.names.get(addr, False), addr), charset='utf-8') for addr in cc]
for addr in cc]
except TypeError:
# Python v2.x Support (no charset keyword)
# Format our cc addresses to support the Name field
payload['cc'] = [formataddr( # pragma: no branch
(self.names.get(addr, False), addr))
for addr in cc]
# Format our bcc addresses to support the Name field # Format our bcc addresses to support the Name field
if bcc: if bcc:

View file

@ -1,6 +1,6 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
# #
# Copyright (C) 2020 Chris Caron <lead2gold@gmail.com> # Copyright (C) 2022 Chris Caron <lead2gold@gmail.com>
# All rights reserved. # All rights reserved.
# #
# This code is licensed under the MIT License. # This code is licensed under the MIT License.
@ -23,8 +23,10 @@
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
# THE SOFTWARE. # THE SOFTWARE.
import re
import requests import requests
from json import dumps from json import dumps
import base64
from .NotifyBase import NotifyBase from .NotifyBase import NotifyBase
from ..common import NotifyType from ..common import NotifyType
@ -35,6 +37,10 @@ from ..URLBase import PrivacyMode
from ..AppriseLocale import gettext_lazy as _ from ..AppriseLocale import gettext_lazy as _
GROUP_REGEX = re.compile(
r'^\s*((\@|\%40)?(group\.)|\@|\%40)(?P<group>[a-z0-9_=-]+)', re.I)
class NotifySignalAPI(NotifyBase): class NotifySignalAPI(NotifyBase):
""" """
A wrapper for SignalAPI Notifications A wrapper for SignalAPI Notifications
@ -113,6 +119,13 @@ class NotifySignalAPI(NotifyBase):
'regex': (r'^[0-9\s)(+-]+$', 'i'), 'regex': (r'^[0-9\s)(+-]+$', 'i'),
'map_to': 'targets', 'map_to': 'targets',
}, },
'target_channel': {
'name': _('Target Group ID'),
'type': 'string',
'prefix': '@',
'regex': (r'^[a-z0-9_=-]+$', 'i'),
'map_to': 'targets',
},
'targets': { 'targets': {
'name': _('Targets'), 'name': _('Targets'),
'type': 'list:string', 'type': 'list:string',
@ -173,23 +186,33 @@ class NotifySignalAPI(NotifyBase):
for target in parse_phone_no(targets): for target in parse_phone_no(targets):
# Validate targets and drop bad ones: # Validate targets and drop bad ones:
result = is_phone_no(target) result = is_phone_no(target)
if not result: if result:
self.logger.warning( # store valid phone number
'Dropped invalid phone # ' self.targets.append('+{}'.format(result['full']))
'({}) specified.'.format(target),
)
self.invalid_targets.append(target)
continue continue
# store valid phone number result = GROUP_REGEX.match(target)
self.targets.append('+{}'.format(result['full'])) if result:
# Just store group information
self.targets.append(
'group.{}'.format(result.group('group')))
continue
self.logger.warning(
'Dropped invalid phone/group '
'({}) specified.'.format(target),
)
self.invalid_targets.append(target)
continue
else: else:
# Send a message to ourselves # Send a message to ourselves
self.targets.append(self.source) self.targets.append(self.source)
return return
def send(self, body, title='', notify_type=NotifyType.INFO, **kwargs): def send(self, body, title='', notify_type=NotifyType.INFO, attach=None,
**kwargs):
""" """
Perform Signal API Notification Perform Signal API Notification
""" """
@ -203,12 +226,50 @@ class NotifySignalAPI(NotifyBase):
# error tracking (used for function return) # error tracking (used for function return)
has_error = False has_error = False
attachments = []
if attach:
for attachment in attach:
# Perform some simple error checking
if not attachment:
# We could not access the attachment
self.logger.error(
'Could not access attachment {}.'.format(
attachment.url(privacy=True)))
return False
try:
with open(attachment.path, 'rb') as f:
# Prepare our Attachment in Base64
attachments.append(
base64.b64encode(f.read()).decode('utf-8'))
except (OSError, IOError) as e:
self.logger.warning(
'An I/O error occurred while reading {}.'.format(
attachment.name if attachment else 'attachment'))
self.logger.debug('I/O Exception: %s' % str(e))
return False
# Prepare our headers # Prepare our headers
headers = { headers = {
'User-Agent': self.app_id, 'User-Agent': self.app_id,
'Content-Type': 'application/json', 'Content-Type': 'application/json',
} }
# Format defined here:
# https://bbernhard.github.io/signal-cli-rest-api\
# /#/Messages/post_v2_send
# Example:
# {
# "base64_attachments": [
# "string"
# ],
# "message": "string",
# "number": "string",
# "recipients": [
# "string"
# ]
# }
# Prepare our payload # Prepare our payload
payload = { payload = {
'message': "{}{}".format( 'message': "{}{}".format(
@ -218,6 +279,10 @@ class NotifySignalAPI(NotifyBase):
"recipients": [] "recipients": []
} }
if attachments:
# Store our attachments
payload['base64_attachments'] = attachments
# Determine Authentication # Determine Authentication
auth = None auth = None
if self.user: if self.user:
@ -255,7 +320,8 @@ class NotifySignalAPI(NotifyBase):
verify=self.verify_certificate, verify=self.verify_certificate,
timeout=self.request_timeout, timeout=self.request_timeout,
) )
if r.status_code != requests.codes.ok: if r.status_code not in (
requests.codes.ok, requests.codes.created):
# We had a problem # We had a problem
status_str = \ status_str = \
NotifySignalAPI.http_response_code_lookup( NotifySignalAPI.http_response_code_lookup(
@ -339,7 +405,11 @@ class NotifySignalAPI(NotifyBase):
targets = self.invalid_targets targets = self.invalid_targets
else: else:
targets = list(self.targets) # append @ to non-phone number entries as they are groups
# Remove group. prefix as well
targets = \
['@{}'.format(x[6:]) if x[0] != '+'
else x for x in self.targets]
return '{schema}://{auth}{hostname}{port}/{src}/{dst}?{params}'.format( return '{schema}://{auth}{hostname}{port}/{src}/{dst}?{params}'.format(
schema=self.secure_protocol if self.secure else self.protocol, schema=self.secure_protocol if self.secure else self.protocol,
@ -350,7 +420,7 @@ class NotifySignalAPI(NotifyBase):
else ':{}'.format(self.port), else ':{}'.format(self.port),
src=self.source, src=self.source,
dst='/'.join( dst='/'.join(
[NotifySignalAPI.quote(x, safe='') for x in targets]), [NotifySignalAPI.quote(x, safe='@+') for x in targets]),
params=NotifySignalAPI.urlencode(params), params=NotifySignalAPI.urlencode(params),
) )

View file

@ -33,7 +33,6 @@
# from). Activated phone numbers can be found on your dashboard here: # from). Activated phone numbers can be found on your dashboard here:
# - https://dashboard.sinch.com/numbers/your-numbers/numbers # - https://dashboard.sinch.com/numbers/your-numbers/numbers
# #
import six
import requests import requests
import json import json
@ -46,7 +45,7 @@ from ..utils import validate_regex
from ..AppriseLocale import gettext_lazy as _ from ..AppriseLocale import gettext_lazy as _
class SinchRegion(object): class SinchRegion:
""" """
Defines the Sinch Server Regions Defines the Sinch Server Regions
""" """
@ -192,7 +191,7 @@ class NotifySinch(NotifyBase):
# Setup our region # Setup our region
self.region = self.template_args['region']['default'] \ self.region = self.template_args['region']['default'] \
if not isinstance(region, six.string_types) else region.lower() if not isinstance(region, str) else region.lower()
if self.region and self.region not in SINCH_REGIONS: if self.region and self.region not in SINCH_REGIONS:
msg = 'The region specified ({}) is invalid.'.format(region) msg = 'The region specified ({}) is invalid.'.format(region)
self.logger.warning(msg) self.logger.warning(msg)

View file

@ -94,7 +94,7 @@ SLACK_HTTP_ERROR_MAP = {
CHANNEL_LIST_DELIM = re.compile(r'[ \t\r\n,#\\/]+') CHANNEL_LIST_DELIM = re.compile(r'[ \t\r\n,#\\/]+')
class SlackMode(object): class SlackMode:
""" """
Tracks the mode of which we're using Slack Tracks the mode of which we're using Slack
""" """

View file

@ -80,7 +80,7 @@ SPARKPOST_HTTP_ERROR_MAP = {
# Priorities # Priorities
class SparkPostRegion(object): class SparkPostRegion:
US = 'us' US = 'us'
EU = 'eu' EU = 'eu'
@ -503,14 +503,9 @@ class NotifySparkPost(NotifyBase):
# Send in batches if identified to do so # Send in batches if identified to do so
batch_size = 1 if not self.batch else self.default_batch_size batch_size = 1 if not self.batch else self.default_batch_size
try: reply_to = formataddr((self.from_name if self.from_name else False,
reply_to = formataddr((self.from_name if self.from_name else False, self.from_addr), charset='utf-8')
self.from_addr), charset='utf-8')
except TypeError:
# Python v2.x Support (no charset keyword)
# Format our cc addresses to support the Name field
reply_to = formataddr((self.from_name if self.from_name else False,
self.from_addr))
payload = { payload = {
"options": { "options": {
# When set to True, an image is included with the email which # When set to True, an image is included with the email which

View file

@ -52,7 +52,7 @@ from ..AppriseLocale import gettext_lazy as _
# specified. If not, we use the user of the person sending the notification # specified. If not, we use the user of the person sending the notification
# Finally the channel identifier is detected # Finally the channel identifier is detected
CHANNEL_REGEX = re.compile( CHANNEL_REGEX = re.compile(
r'^\s*(#|%23)?((@|%40)?(?P<user>[a-z0-9_]+)([/\\]|%2F))?' r'^\s*(\#|\%23)?((\@|\%40)?(?P<user>[a-z0-9_]+)([/\\]|\%2F))?'
r'(?P<channel>[a-z0-9_-]+)\s*$', re.I) r'(?P<channel>[a-z0-9_-]+)\s*$', re.I)

View file

@ -1,6 +1,6 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
# #
# Copyright (C) 2021 <example@example.com> # Copyright (C) 2021 Chris Caron <lead2gold@gmail.com>
# All rights reserved. # All rights reserved.
# #
# This code is licensed under the MIT License. # This code is licensed under the MIT License.
@ -42,7 +42,7 @@ from ..AppriseLocale import gettext_lazy as _
# calls # calls
class StrmlabsCall(object): class StrmlabsCall:
ALERT = 'ALERTS' ALERT = 'ALERTS'
DONATION = 'DONATIONS' DONATION = 'DONATIONS'
@ -55,7 +55,7 @@ STRMLABS_CALLS = (
# alerts # alerts
class StrmlabsAlert(object): class StrmlabsAlert:
FOLLOW = 'follow' FOLLOW = 'follow'
SUBSCRIPTION = 'subscription' SUBSCRIPTION = 'subscription'
DONATION = 'donation' DONATION = 'donation'

View file

@ -23,7 +23,6 @@
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
# THE SOFTWARE. # THE SOFTWARE.
import os import os
import six
import syslog import syslog
import socket import socket
@ -101,7 +100,7 @@ SYSLOG_FACILITY_RMAP = {
} }
class SyslogMode(object): class SyslogMode:
# A local query # A local query
LOCAL = "local" LOCAL = "local"
@ -217,7 +216,7 @@ class NotifySyslog(NotifyBase):
self.template_tokens['facility']['default']] self.template_tokens['facility']['default']]
self.mode = self.template_args['mode']['default'] \ self.mode = self.template_args['mode']['default'] \
if not isinstance(mode, six.string_types) else mode.lower() if not isinstance(mode, str) else mode.lower()
if self.mode not in SYSLOG_MODES: if self.mode not in SYSLOG_MODES:
msg = 'The mode specified ({}) is invalid.'.format(mode) msg = 'The mode specified ({}) is invalid.'.format(mode)

View file

@ -177,44 +177,85 @@ class NotifyTelegram(NotifyBase):
# characters passed into it. to handle this situation, we need to # characters passed into it. to handle this situation, we need to
# search the body for these sequences and convert them to the # search the body for these sequences and convert them to the
# output the user expected # output the user expected
__telegram_escape_html_dict = { __telegram_escape_html_entries = (
# New Lines # Comments
re.compile(r'<\s*/?br\s*/?>\r*\n?', re.I): '\r\n', (re.compile(
re.compile(r'<\s*/(br|p|div|li)[^>]*>\r*\n?', re.I): '\r\n', r'\s*<!.+?-->\s*',
(re.I | re.M | re.S)), '', {}),
# The following characters can be altered to become supported
re.compile(r'<\s*pre[^>]*>', re.I): '<code>',
re.compile(r'<\s*/pre[^>]*>', re.I): '</code>',
# the following tags are not supported # the following tags are not supported
re.compile( (re.compile(
r'<\s*(br|p|div|span|body|script|meta|html|font' r'\s*<\s*(!?DOCTYPE|p|div|span|body|script|link|'
r'|label|iframe|li|ol|ul|source|script)[^>]*>', re.I): '', r'meta|html|font|head|label|form|input|textarea|select|iframe|'
r'source|script)([^a-z0-9>][^>]*)?>\s*',
(re.I | re.M | re.S)), '', {}),
re.compile( # All closing tags to be removed are put here
r'<\s*/(span|body|script|meta|html|font' (re.compile(
r'|label|iframe|ol|ul|source|script)[^>]*>', re.I): '', r'\s*<\s*/(span|body|script|meta|html|font|head|'
r'label|form|input|textarea|select|ol|ul|link|'
# Italic r'iframe|source|script)([^a-z0-9>][^>]*)?>\s*',
re.compile(r'<\s*(caption|em)[^>]*>', re.I): '<i>', (re.I | re.M | re.S)), '', {}),
re.compile(r'<\s*/(caption|em)[^>]*>', re.I): '</i>',
# Bold # Bold
re.compile(r'<\s*(h[1-6]|title|strong)[^>]*>', re.I): '<b>', (re.compile(
re.compile(r'<\s*/(h[1-6]|title|strong)[^>]*>', re.I): '</b>', r'<\s*(strong)([^a-z0-9>][^>]*)?>',
(re.I | re.M | re.S)), '<b>', {}),
(re.compile(
r'<\s*/\s*(strong)([^a-z0-9>][^>]*)?>',
(re.I | re.M | re.S)), '</b>', {}),
(re.compile(
r'\s*<\s*(h[1-6]|title)([^a-z0-9>][^>]*)?>\s*',
(re.I | re.M | re.S)), '{}<b>', {'html': '\r\n'}),
(re.compile(
r'\s*<\s*/\s*(h[1-6]|title)([^a-z0-9>][^>]*)?>\s*',
(re.I | re.M | re.S)),
'</b>{}', {'html': '<br/>'}),
# Italic
(re.compile(
r'<\s*(caption|em)([^a-z0-9>][^>]*)?>',
(re.I | re.M | re.S)), '<i>', {}),
(re.compile(
r'<\s*/\s*(caption|em)([^a-z0-9>][^>]*)?>',
(re.I | re.M | re.S)), '</i>', {}),
# Bullet Lists
(re.compile(
r'<\s*li([^a-z0-9>][^>]*)?>\s*',
(re.I | re.M | re.S)), ' -', {}),
# convert pre tags to code (supported by Telegram)
(re.compile(
r'<\s*pre([^a-z0-9>][^>]*)?>',
(re.I | re.M | re.S)), '{}<code>', {'html': '\r\n'}),
(re.compile(
r'<\s*/\s*pre([^a-z0-9>][^>]*)?>',
(re.I | re.M | re.S)), '</code>{}', {'html': '\r\n'}),
# New Lines
(re.compile(
r'\s*<\s*/?\s*(ol|ul|br|hr)\s*/?>\s*',
(re.I | re.M | re.S)), '\r\n', {}),
(re.compile(
r'\s*<\s*/\s*(br|p|hr|li|div)([^a-z0-9>][^>]*)?>\s*',
(re.I | re.M | re.S)), '\r\n', {}),
# HTML Spaces (&nbsp;) and tabs (&emsp;) aren't supported # HTML Spaces (&nbsp;) and tabs (&emsp;) aren't supported
# See https://core.telegram.org/bots/api#html-style # See https://core.telegram.org/bots/api#html-style
re.compile(r'\&nbsp;?', re.I): ' ', (re.compile(r'\&nbsp;?', re.I), ' ', {}),
# Tabs become 3 spaces # Tabs become 3 spaces
re.compile(r'\&emsp;?', re.I): ' ', (re.compile(r'\&emsp;?', re.I), ' ', {}),
# Some characters get re-escaped by the Telegram upstream # Some characters get re-escaped by the Telegram upstream
# service so we need to convert these back, # service so we need to convert these back,
re.compile(r'\&apos;?', re.I): '\'', (re.compile(r'\&apos;?', re.I), '\'', {}),
re.compile(r'\&quot;?', re.I): '"', (re.compile(r'\&quot;?', re.I), '"', {}),
}
# New line cleanup
(re.compile(r'\r*\n[\r\n]+', re.I), '\r\n', {}),
)
# Define our template tokens # Define our template tokens
template_tokens = dict(NotifyBase.template_tokens, **{ template_tokens = dict(NotifyBase.template_tokens, **{
@ -597,38 +638,19 @@ class NotifyTelegram(NotifyBase):
# Use Telegram's HTML mode # Use Telegram's HTML mode
payload['parse_mode'] = 'HTML' payload['parse_mode'] = 'HTML'
for r, v in self.__telegram_escape_html_dict.items(): for r, v, m in self.__telegram_escape_html_entries:
body = r.sub(v, body, re.I)
if 'html' in m:
# Handle special cases where we need to alter new lines
# for presentation purposes
v = v.format(m['html'] if body_format in (
NotifyFormat.HTML, NotifyFormat.MARKDOWN) else '')
body = r.sub(v, body)
# Prepare our payload based on HTML or TEXT # Prepare our payload based on HTML or TEXT
payload['text'] = body payload['text'] = body
# else: # self.notify_format == NotifyFormat.TEXT:
# # Use Telegram's HTML mode
# payload['parse_mode'] = 'HTML'
# # Further html escaping required...
# telegram_escape_text_dict = {
# # We need to escape characters that conflict with html
# # entity blocks (< and >) when displaying text
# r'>': '&gt;',
# r'<': '&lt;',
# r'\&': '&amp;',
# }
# # Create a regular expression from the dictionary keys
# text_regex = re.compile("(%s)" % "|".join(
# map(re.escape, telegram_escape_text_dict.keys())).lower(),
# re.I)
# # For each match, look-up corresponding value in dictionary
# body = text_regex.sub( # pragma: no branch
# lambda mo: telegram_escape_text_dict[
# mo.string[mo.start():mo.end()]], body)
# # prepare our payload based on HTML or TEXT
# payload['text'] = body
# Create a copy of the chat_ids list # Create a copy of the chat_ids list
targets = list(self.targets) targets = list(self.targets)
while len(targets): while len(targets):

View file

@ -785,7 +785,7 @@ class NotifyTwist(NotifyBase):
def __del__(self): def __del__(self):
""" """
Deconstructor Destructor
""" """
try: try:
self.logout() self.logout()
@ -808,14 +808,14 @@ class NotifyTwist(NotifyBase):
except ImportError: # pragma: no cover except ImportError: # pragma: no cover
# The actual exception is `ModuleNotFoundError` however ImportError # The actual exception is `ModuleNotFoundError` however ImportError
# grants us backwards compatiblity with versions of Python older # grants us backwards compatibility with versions of Python older
# than v3.6 # than v3.6
# Python code that makes early calls to sys.exit() can cause # Python code that makes early calls to sys.exit() can cause
# the __del__() code to run. However in some newer versions of # the __del__() code to run. However, in some newer versions of
# Python, this causes the `sys` library to no longer be # Python, this causes the `sys` library to no longer be
# available. The stack overflow also goes on to suggest that # available. The stack overflow also goes on to suggest that
# it's not wise to use the __del__() as a deconstructor # it's not wise to use the __del__() as a destructor
# which is the case here. # which is the case here.
# https://stackoverflow.com/questions/67218341/\ # https://stackoverflow.com/questions/67218341/\
@ -827,6 +827,6 @@ class NotifyTwist(NotifyBase):
# /1481488/what-is-the-del-method-and-how-do-i-call-it # /1481488/what-is-the-del-method-and-how-do-i-call-it
# At this time it seems clean to try to log out (if we can) # At this time it seems clean to try to log out (if we can)
# but not throw any unessisary exceptions (like this one) to # but not throw any unnecessary exceptions (like this one) to
# the end user if we don't have to. # the end user if we don't have to.
pass pass

View file

@ -26,7 +26,6 @@
# See https://developer.twitter.com/en/docs/direct-messages/\ # See https://developer.twitter.com/en/docs/direct-messages/\
# sending-and-receiving/api-reference/new-event.html # sending-and-receiving/api-reference/new-event.html
import re import re
import six
import requests import requests
from copy import deepcopy from copy import deepcopy
from datetime import datetime from datetime import datetime
@ -45,7 +44,7 @@ from ..attachment.AttachBase import AttachBase
IS_USER = re.compile(r'^\s*@?(?P<user>[A-Z0-9_]+)$', re.I) IS_USER = re.compile(r'^\s*@?(?P<user>[A-Z0-9_]+)$', re.I)
class TwitterMessageMode(object): class TwitterMessageMode:
""" """
Twitter Message Mode Twitter Message Mode
""" """
@ -223,7 +222,7 @@ class NotifyTwitter(NotifyBase):
# Store our webhook mode # Store our webhook mode
self.mode = None \ self.mode = None \
if not isinstance(mode, six.string_types) else mode.lower() if not isinstance(mode, str) else mode.lower()
# Set Cache Flag # Set Cache Flag
self.cache = cache self.cache = cache

View file

@ -39,24 +39,24 @@ from ..utils import validate_regex
from ..AppriseLocale import gettext_lazy as _ from ..AppriseLocale import gettext_lazy as _
class NotifyNexmo(NotifyBase): class NotifyVonage(NotifyBase):
""" """
A wrapper for Nexmo Notifications A wrapper for Vonage Notifications
""" """
# The default descriptive name associated with the Notification # The default descriptive name associated with the Notification
service_name = 'Nexmo' service_name = 'Vonage'
# The services URL # The services URL
service_url = 'https://dashboard.nexmo.com/' service_url = 'https://dashboard.nexmo.com/'
# The default protocol # The default protocol (nexmo kept for backwards compatibility)
secure_protocol = 'nexmo' secure_protocol = ('vonage', 'nexmo')
# A URL that takes you to the setup/help of the specific protocol # A URL that takes you to the setup/help of the specific protocol
setup_url = 'https://github.com/caronc/apprise/wiki/Notify_nexmo' setup_url = 'https://github.com/caronc/apprise/wiki/Notify_nexmo'
# Nexmo uses the http protocol with JSON requests # Vonage uses the http protocol with JSON requests
notify_url = 'https://rest.nexmo.com/sms/json' notify_url = 'https://rest.nexmo.com/sms/json'
# The maximum length of the body # The maximum length of the body
@ -124,7 +124,7 @@ class NotifyNexmo(NotifyBase):
}, },
# Default Time To Live # Default Time To Live
# By default Nexmo attempt delivery for 72 hours, however the maximum # By default Vonage attempt delivery for 72 hours, however the maximum
# effective value depends on the operator and is typically 24 - 48 # effective value depends on the operator and is typically 24 - 48
# hours. We recommend this value should be kept at its default or at # hours. We recommend this value should be kept at its default or at
# least 30 minutes. # least 30 minutes.
@ -140,15 +140,15 @@ class NotifyNexmo(NotifyBase):
def __init__(self, apikey, secret, source, targets=None, ttl=None, def __init__(self, apikey, secret, source, targets=None, ttl=None,
**kwargs): **kwargs):
""" """
Initialize Nexmo Object Initialize Vonage Object
""" """
super(NotifyNexmo, self).__init__(**kwargs) super(NotifyVonage, self).__init__(**kwargs)
# API Key (associated with project) # API Key (associated with project)
self.apikey = validate_regex( self.apikey = validate_regex(
apikey, *self.template_tokens['apikey']['regex']) apikey, *self.template_tokens['apikey']['regex'])
if not self.apikey: if not self.apikey:
msg = 'An invalid Nexmo API Key ' \ msg = 'An invalid Vonage API Key ' \
'({}) was specified.'.format(apikey) '({}) was specified.'.format(apikey)
self.logger.warning(msg) self.logger.warning(msg)
raise TypeError(msg) raise TypeError(msg)
@ -157,7 +157,7 @@ class NotifyNexmo(NotifyBase):
self.secret = validate_regex( self.secret = validate_regex(
secret, *self.template_tokens['secret']['regex']) secret, *self.template_tokens['secret']['regex'])
if not self.secret: if not self.secret:
msg = 'An invalid Nexmo API Secret ' \ msg = 'An invalid Vonage API Secret ' \
'({}) was specified.'.format(secret) '({}) was specified.'.format(secret)
self.logger.warning(msg) self.logger.warning(msg)
raise TypeError(msg) raise TypeError(msg)
@ -173,7 +173,7 @@ class NotifyNexmo(NotifyBase):
if self.ttl < self.template_args['ttl']['min'] or \ if self.ttl < self.template_args['ttl']['min'] or \
self.ttl > self.template_args['ttl']['max']: self.ttl > self.template_args['ttl']['max']:
msg = 'The Nexmo TTL specified ({}) is out of range.'\ msg = 'The Vonage TTL specified ({}) is out of range.'\
.format(self.ttl) .format(self.ttl)
self.logger.warning(msg) self.logger.warning(msg)
raise TypeError(msg) raise TypeError(msg)
@ -211,7 +211,7 @@ class NotifyNexmo(NotifyBase):
def send(self, body, title='', notify_type=NotifyType.INFO, **kwargs): def send(self, body, title='', notify_type=NotifyType.INFO, **kwargs):
""" """
Perform Nexmo Notification Perform Vonage Notification
""" """
# error tracking (used for function return) # error tracking (used for function return)
@ -250,9 +250,9 @@ class NotifyNexmo(NotifyBase):
payload['to'] = target payload['to'] = target
# Some Debug Logging # Some Debug Logging
self.logger.debug('Nexmo POST URL: {} (cert_verify={})'.format( self.logger.debug('Vonage POST URL: {} (cert_verify={})'.format(
self.notify_url, self.verify_certificate)) self.notify_url, self.verify_certificate))
self.logger.debug('Nexmo Payload: {}' .format(payload)) self.logger.debug('Vonage Payload: {}' .format(payload))
# Always call throttle before any remote server i/o is made # Always call throttle before any remote server i/o is made
self.throttle() self.throttle()
@ -269,11 +269,11 @@ class NotifyNexmo(NotifyBase):
if r.status_code != requests.codes.ok: if r.status_code != requests.codes.ok:
# We had a problem # We had a problem
status_str = \ status_str = \
NotifyNexmo.http_response_code_lookup( NotifyVonage.http_response_code_lookup(
r.status_code) r.status_code)
self.logger.warning( self.logger.warning(
'Failed to send Nexmo notification to {}: ' 'Failed to send Vonage notification to {}: '
'{}{}error={}.'.format( '{}{}error={}.'.format(
target, target,
status_str, status_str,
@ -288,11 +288,12 @@ class NotifyNexmo(NotifyBase):
continue continue
else: else:
self.logger.info('Sent Nexmo notification to %s.' % target) self.logger.info(
'Sent Vonage notification to %s.' % target)
except requests.RequestException as e: except requests.RequestException as e:
self.logger.warning( self.logger.warning(
'A Connection error occurred sending Nexmo:%s ' 'A Connection error occurred sending Vonage:%s '
'notification.' % target 'notification.' % target
) )
self.logger.debug('Socket Exception: %s' % str(e)) self.logger.debug('Socket Exception: %s' % str(e))
@ -317,14 +318,14 @@ class NotifyNexmo(NotifyBase):
params.update(self.url_parameters(privacy=privacy, *args, **kwargs)) params.update(self.url_parameters(privacy=privacy, *args, **kwargs))
return '{schema}://{key}:{secret}@{source}/{targets}/?{params}'.format( return '{schema}://{key}:{secret}@{source}/{targets}/?{params}'.format(
schema=self.secure_protocol, schema=self.secure_protocol[0],
key=self.pprint(self.apikey, privacy, safe=''), key=self.pprint(self.apikey, privacy, safe=''),
secret=self.pprint( secret=self.pprint(
self.secret, privacy, mode=PrivacyMode.Secret, safe=''), self.secret, privacy, mode=PrivacyMode.Secret, safe=''),
source=NotifyNexmo.quote(self.source, safe=''), source=NotifyVonage.quote(self.source, safe=''),
targets='/'.join( targets='/'.join(
[NotifyNexmo.quote(x, safe='') for x in self.targets]), [NotifyVonage.quote(x, safe='') for x in self.targets]),
params=NotifyNexmo.urlencode(params)) params=NotifyVonage.urlencode(params))
@staticmethod @staticmethod
def parse_url(url): def parse_url(url):
@ -340,46 +341,46 @@ class NotifyNexmo(NotifyBase):
# Get our entries; split_path() looks after unquoting content for us # Get our entries; split_path() looks after unquoting content for us
# by default # by default
results['targets'] = NotifyNexmo.split_path(results['fullpath']) results['targets'] = NotifyVonage.split_path(results['fullpath'])
# The hostname is our source number # The hostname is our source number
results['source'] = NotifyNexmo.unquote(results['host']) results['source'] = NotifyVonage.unquote(results['host'])
# Get our account_side and auth_token from the user/pass config # Get our account_side and auth_token from the user/pass config
results['apikey'] = NotifyNexmo.unquote(results['user']) results['apikey'] = NotifyVonage.unquote(results['user'])
results['secret'] = NotifyNexmo.unquote(results['password']) results['secret'] = NotifyVonage.unquote(results['password'])
# API Key # API Key
if 'key' in results['qsd'] and len(results['qsd']['key']): if 'key' in results['qsd'] and len(results['qsd']['key']):
# Extract the API Key from an argument # Extract the API Key from an argument
results['apikey'] = \ results['apikey'] = \
NotifyNexmo.unquote(results['qsd']['key']) NotifyVonage.unquote(results['qsd']['key'])
# API Secret # API Secret
if 'secret' in results['qsd'] and len(results['qsd']['secret']): if 'secret' in results['qsd'] and len(results['qsd']['secret']):
# Extract the API Secret from an argument # Extract the API Secret from an argument
results['secret'] = \ results['secret'] = \
NotifyNexmo.unquote(results['qsd']['secret']) NotifyVonage.unquote(results['qsd']['secret'])
# Support the 'from' and 'source' variable so that we can support # Support the 'from' and 'source' variable so that we can support
# targets this way too. # targets this way too.
# The 'from' makes it easier to use yaml configuration # The 'from' makes it easier to use yaml configuration
if 'from' in results['qsd'] and len(results['qsd']['from']): if 'from' in results['qsd'] and len(results['qsd']['from']):
results['source'] = \ results['source'] = \
NotifyNexmo.unquote(results['qsd']['from']) NotifyVonage.unquote(results['qsd']['from'])
if 'source' in results['qsd'] and len(results['qsd']['source']): if 'source' in results['qsd'] and len(results['qsd']['source']):
results['source'] = \ results['source'] = \
NotifyNexmo.unquote(results['qsd']['source']) NotifyVonage.unquote(results['qsd']['source'])
# Support the 'ttl' variable # Support the 'ttl' variable
if 'ttl' in results['qsd'] and len(results['qsd']['ttl']): if 'ttl' in results['qsd'] and len(results['qsd']['ttl']):
results['ttl'] = \ results['ttl'] = \
NotifyNexmo.unquote(results['qsd']['ttl']) NotifyVonage.unquote(results['qsd']['ttl'])
# Support the 'to' variable so that we can support rooms this way too # Support the 'to' variable so that we can support rooms this way too
# The 'to' makes it easier to use yaml configuration # The 'to' makes it easier to use yaml configuration
if 'to' in results['qsd'] and len(results['qsd']['to']): if 'to' in results['qsd'] and len(results['qsd']['to']):
results['targets'] += \ results['targets'] += \
NotifyNexmo.parse_phone_no(results['qsd']['to']) NotifyVonage.parse_phone_no(results['qsd']['to'])
return results return results

View file

@ -203,9 +203,9 @@ class NotifyWindows(NotifyBase):
self.logger.info('Sent Windows notification.') self.logger.info('Sent Windows notification.')
except Exception: except Exception as e:
self.logger.warning('Failed to send Windows notification.') self.logger.warning('Failed to send Windows notification.')
self.logger.exception('Windows Exception') self.logger.debug('Windows Exception: {}', str(e))
return False return False
return True return True

View file

@ -24,7 +24,6 @@
# THE SOFTWARE. # THE SOFTWARE.
import re import re
import six
import requests import requests
import base64 import base64
@ -157,11 +156,11 @@ class NotifyXML(NotifyBase):
</soapenv:Envelope>""" </soapenv:Envelope>"""
self.fullpath = kwargs.get('fullpath') self.fullpath = kwargs.get('fullpath')
if not isinstance(self.fullpath, six.string_types): if not isinstance(self.fullpath, str):
self.fullpath = '' self.fullpath = ''
self.method = self.template_args['method']['default'] \ self.method = self.template_args['method']['default'] \
if not isinstance(method, six.string_types) else method.upper() if not isinstance(method, str) else method.upper()
if self.method not in METHODS: if self.method not in METHODS:
msg = 'The method specified ({}) is invalid.'.format(method) msg = 'The method specified ({}) is invalid.'.format(method)
@ -284,8 +283,7 @@ class NotifyXML(NotifyBase):
try: try:
with open(attachment.path, 'rb') as f: with open(attachment.path, 'rb') as f:
# Output must be in a DataURL format (that's what # Prepare our Attachment in Base64
# PushSafer calls it):
entry = \ entry = \
'<Attachment filename="{}" mimetype="{}">'.format( '<Attachment filename="{}" mimetype="{}">'.format(
NotifyXML.escape_html( NotifyXML.escape_html(
@ -415,17 +413,9 @@ class NotifyXML(NotifyBase):
for x, y in results['qsd:'].items()} for x, y in results['qsd:'].items()}
# Add our headers that the user can potentially over-ride if they wish # Add our headers that the user can potentially over-ride if they wish
# to to our returned result set # to to our returned result set and tidy entries by unquoting them
results['headers'] = results['qsd+']
if results['qsd-']:
results['headers'].update(results['qsd-'])
NotifyBase.logger.deprecate(
"minus (-) based XML header tokens are being "
"removed; use the plus (+) symbol instead.")
# Tidy our header entries by unquoting them
results['headers'] = {NotifyXML.unquote(x): NotifyXML.unquote(y) results['headers'] = {NotifyXML.unquote(x): NotifyXML.unquote(y)
for x, y in results['headers'].items()} for x, y in results['qsd+'].items()}
# Set method if not otherwise set # Set method if not otherwise set
if 'method' in results['qsd'] and len(results['qsd']['method']): if 'method' in results['qsd'] and len(results['qsd']['method']):

View file

@ -1,203 +0,0 @@
# -*- coding: utf-8 -*-
import ssl
from os.path import isfile
import logging
# Default our global support flag
SLIXMPP_SUPPORT_AVAILABLE = False
try:
# Import slixmpp if available
import slixmpp
import asyncio
SLIXMPP_SUPPORT_AVAILABLE = True
except ImportError:
# No problem; we just simply can't support this plugin because we're
# either using Linux, or simply do not have slixmpp installed.
pass
class SliXmppAdapter(object):
"""
Wrapper to slixmpp
"""
# Reference to XMPP client.
xmpp = None
# Whether everything succeeded
success = False
# The default protocol
protocol = 'xmpp'
# The default secure protocol
secure_protocol = 'xmpps'
# Taken from https://golang.org/src/crypto/x509/root_linux.go
CA_CERTIFICATE_FILE_LOCATIONS = [
# Debian/Ubuntu/Gentoo etc.
"/etc/ssl/certs/ca-certificates.crt",
# Fedora/RHEL 6
"/etc/pki/tls/certs/ca-bundle.crt",
# OpenSUSE
"/etc/ssl/ca-bundle.pem",
# OpenELEC
"/etc/pki/tls/cacert.pem",
# CentOS/RHEL 7
"/etc/pki/ca-trust/extracted/pem/tls-ca-bundle.pem",
]
# This entry is a bit hacky, but it allows us to unit-test this library
# in an environment that simply doesn't have the slixmpp package
# available to us.
#
# If anyone is seeing this had knows a better way of testing this
# outside of what is defined in test/test_xmpp_plugin.py, please
# let me know! :)
_enabled = SLIXMPP_SUPPORT_AVAILABLE
def __init__(self, host=None, port=None, secure=False,
verify_certificate=True, xep=None, jid=None, password=None,
body=None, subject=None, targets=None, before_message=None,
logger=None):
"""
Initialize our SliXmppAdapter object
"""
self.host = host
self.port = port
self.secure = secure
self.verify_certificate = verify_certificate
self.xep = xep
self.jid = jid
self.password = password
self.body = body
self.subject = subject
self.targets = targets
self.before_message = before_message
self.logger = logger or logging.getLogger(__name__)
# Use the Apprise log handlers for configuring the slixmpp logger.
apprise_logger = logging.getLogger('apprise')
sli_logger = logging.getLogger('slixmpp')
for handler in apprise_logger.handlers:
sli_logger.addHandler(handler)
sli_logger.setLevel(apprise_logger.level)
if not self.load():
raise ValueError("Invalid XMPP Configuration")
def load(self):
try:
asyncio.get_event_loop()
except RuntimeError:
# slixmpp can not handle not having an event_loop
# see: https://lab.louiz.org/poezio/slixmpp/-/issues/3456
# This is a work-around to this problem
asyncio.set_event_loop(asyncio.new_event_loop())
# Prepare our object
self.xmpp = slixmpp.ClientXMPP(self.jid, self.password)
# Register our session
self.xmpp.add_event_handler("session_start", self.session_start)
for xep in self.xep:
# Load xep entries
try:
self.xmpp.register_plugin('xep_{0:04d}'.format(xep))
except slixmpp.plugins.base.PluginNotFound:
self.logger.warning(
'Could not register plugin {}'.format(
'xep_{0:04d}'.format(xep)))
return False
if self.secure:
# Don't even try to use the outdated ssl.PROTOCOL_SSLx
self.xmpp.ssl_version = ssl.PROTOCOL_TLSv1
# If the python version supports it, use highest TLS version
# automatically
if hasattr(ssl, "PROTOCOL_TLS"):
# Use the best version of TLS available to us
self.xmpp.ssl_version = ssl.PROTOCOL_TLS
self.xmpp.ca_certs = None
if self.verify_certificate:
# Set the ca_certs variable for certificate verification
self.xmpp.ca_certs = next(
(cert for cert in self.CA_CERTIFICATE_FILE_LOCATIONS
if isfile(cert)), None)
if self.xmpp.ca_certs is None:
self.logger.warning(
'XMPP Secure comunication can not be verified; '
'no local CA certificate file')
return False
# If the user specified a port, skip SRV resolving, otherwise it is a
# lot easier to let slixmpp handle DNS instead of the user.
self.override_connection = \
None if not self.port else (self.host, self.port)
# We're good
return True
def process(self):
"""
Thread that handles the server/client i/o
"""
# Instruct slixmpp to connect to the XMPP service.
if not self.xmpp.connect(
self.override_connection, use_ssl=self.secure):
return False
# Run the asyncio event loop, and return once disconnected,
# for any reason.
self.xmpp.process(forever=False)
return self.success
def session_start(self, *args, **kwargs):
"""
Session Manager
"""
targets = list(self.targets)
if not targets:
# We always default to notifying ourselves
targets.append(self.jid)
while len(targets) > 0:
# Get next target (via JID)
target = targets.pop(0)
# Invoke "before_message" event hook.
self.before_message()
# The message we wish to send, and the JID that will receive it.
self.xmpp.send_message(
mto=target, msubject=self.subject,
mbody=self.body, mtype='chat')
# Using wait=True ensures that the send queue will be
# emptied before ending the session.
self.xmpp.disconnect(wait=True)
# Toggle our success flag
self.success = True

View file

@ -1,311 +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 ..NotifyBase import NotifyBase
from ...URLBase import PrivacyMode
from ...common import NotifyType
from ...utils import parse_list
from ...AppriseLocale import gettext_lazy as _
from .SliXmppAdapter import SliXmppAdapter
# xep string parser
XEP_PARSE_RE = re.compile('^[^1-9]*(?P<xep>[1-9][0-9]{0,3})$')
class NotifyXMPP(NotifyBase):
"""
A wrapper for XMPP Notifications
"""
# Set our global enabled flag
enabled = SliXmppAdapter._enabled
requirements = {
# Define our required packaging in order to work
'packages_required': [
"slixmpp; python_version >= '3.7'",
]
}
# The default descriptive name associated with the Notification
service_name = 'XMPP'
# The services URL
service_url = 'https://xmpp.org/'
# The default protocol
protocol = 'xmpp'
# The default secure protocol
secure_protocol = 'xmpps'
# A URL that takes you to the setup/help of the specific protocol
setup_url = 'https://github.com/caronc/apprise/wiki/Notify_xmpp'
# Lower throttle rate for XMPP
request_rate_per_sec = 0.5
# Our XMPP Adapter we use to communicate through
_adapter = SliXmppAdapter if SliXmppAdapter._enabled else None
# Define object templates
templates = (
'{schema}://{user}:{password}@{host}',
'{schema}://{user}:{password}@{host}:{port}',
'{schema}://{user}:{password}@{host}/{targets}',
'{schema}://{user}:{password}@{host}:{port}/{targets}',
)
# 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,
},
'user': {
'name': _('Username'),
'type': 'string',
'required': True,
},
'password': {
'name': _('Password'),
'type': 'string',
'private': True,
'required': True,
},
'target_jid': {
'name': _('Target JID'),
'type': 'string',
'map_to': 'targets',
},
'targets': {
'name': _('Targets'),
'type': 'list:string',
},
})
# Define our template arguments
template_args = dict(NotifyBase.template_args, **{
'to': {
'alias_of': 'targets',
},
'xep': {
'name': _('XEP'),
'type': 'list:string',
'prefix': 'xep-',
'regex': (r'^[1-9][0-9]{0,3}$', 'i'),
},
'jid': {
'name': _('Source JID'),
'type': 'string',
},
})
def __init__(self, targets=None, jid=None, xep=None, **kwargs):
"""
Initialize XMPP Object
"""
super(NotifyXMPP, self).__init__(**kwargs)
# JID Details:
# - JID's normally have an @ symbol in them, but it is not required
# - Each allowable portion of a JID MUST NOT be more than 1023 bytes
# in length.
# - JID's can identify resource paths at the end separated by slashes
# hence the following is valid: user@example.com/resource/path
# Since JID's can clash with URLs offered by aprise (specifically the
# resource paths we need to allow users an alternative character to
# represent the slashes. The grammer is defined here:
# https://xmpp.org/extensions/xep-0029.html as follows:
#
# <JID> ::= [<node>"@"]<domain>["/"<resource>]
# <node> ::= <conforming-char>[<conforming-char>]*
# <domain> ::= <hname>["."<hname>]*
# <resource> ::= <any-char>[<any-char>]*
# <hname> ::= <let>|<dig>[[<let>|<dig>|"-"]*<let>|<dig>]
# <let> ::= [a-z] | [A-Z]
# <dig> ::= [0-9]
# <conforming-char> ::= #x21 | [#x23-#x25] | [#x28-#x2E] |
# [#x30-#x39] | #x3B | #x3D | #x3F |
# [#x41-#x7E] | [#x80-#xD7FF] |
# [#xE000-#xFFFD] | [#x10000-#x10FFFF]
# <any-char> ::= [#x20-#xD7FF] | [#xE000-#xFFFD] |
# [#x10000-#x10FFFF]
# The best way to do this is to choose characters that aren't allowed
# in this case we will use comma and/or space.
# Assemble our jid using the information available to us:
self.jid = jid
if not (self.user or self.password):
# you must provide a jid/pass for this to work; if no password
# is specified then the user field acts as the password instead
# so we know that if there is no user specified, our url was
# really busted up.
msg = 'You must specify a XMPP password'
self.logger.warning(msg)
raise TypeError(msg)
# See https://xmpp.org/extensions/ for details on xep values
if xep is None:
# Default xep setting
self.xep = [
# xep_0030: Service Discovery
30,
# xep_0199: XMPP Ping
199,
]
else:
# Prepare the list
_xep = parse_list(xep)
self.xep = []
for xep in _xep:
result = XEP_PARSE_RE.match(xep)
if result is not None:
self.xep.append(int(result.group('xep')))
self.logger.debug('Loaded XMPP {}'.format(xep))
else:
self.logger.warning(
"Could not load XMPP {}".format(xep))
# By default we send ourselves a message
if targets:
self.targets = parse_list(targets)
self.targets[0] = self.targets[0][1:]
else:
self.targets = list()
def send(self, body, title='', notify_type=NotifyType.INFO, **kwargs):
"""
Perform XMPP Notification
"""
# Detect our JID if it isn't otherwise specified
jid = self.jid
password = self.password
if not jid:
jid = '{}@{}'.format(self.user, self.host)
try:
# Communicate with XMPP.
xmpp_adapter = self._adapter(
host=self.host, port=self.port, secure=self.secure,
verify_certificate=self.verify_certificate, xep=self.xep,
jid=jid, password=password, body=body, subject=title,
targets=self.targets, before_message=self.throttle,
logger=self.logger)
except ValueError:
# We failed
return False
# Initialize XMPP machinery and begin processing the XML stream.
outcome = xmpp_adapter.process()
return outcome
def url(self, privacy=False, *args, **kwargs):
"""
Returns the URL built dynamically based on specified arguments.
"""
# Our URL parameters
params = self.url_parameters(privacy=privacy, *args, **kwargs)
if self.jid:
params['jid'] = self.jid
if self.xep:
# xep are integers, so we need to just iterate over a list and
# switch them to a string
params['xep'] = ','.join([str(xep) for xep in self.xep])
# Target JID(s) can clash with our existing paths, so we just use comma
# and/or space as a delimiters - %20 = space
jids = '%20'.join([NotifyXMPP.quote(x, safe='') for x in self.targets])
default_schema = self.secure_protocol if self.secure else self.protocol
auth = '{user}:{password}'.format(
user=NotifyXMPP.quote(self.user, safe=''),
password=self.pprint(
self.password, privacy, mode=PrivacyMode.Secret, safe=''))
return '{schema}://{auth}@{hostname}{port}/{jids}?{params}'.format(
auth=auth,
schema=default_schema,
# never encode hostname since we're expecting it to be a valid one
hostname=self.host,
port='' if not self.port
else ':{}'.format(self.port),
jids=jids,
params=NotifyXMPP.urlencode(params),
)
@staticmethod
def parse_url(url):
"""
Parses the URL and returns enough arguments that can allow
us to re-instantiate this object.
"""
results = NotifyBase.parse_url(url)
if not results:
# We're done early as we couldn't load the results
return results
# Get our targets; we ignore path slashes since they identify
# our resources
results['targets'] = NotifyXMPP.parse_list(results['fullpath'])
# Over-ride the xep plugins
if 'xep' in results['qsd'] and len(results['qsd']['xep']):
results['xep'] = \
NotifyXMPP.parse_list(results['qsd']['xep'])
# Over-ride the default (and detected) jid
if 'jid' in results['qsd'] and len(results['qsd']['jid']):
results['jid'] = NotifyXMPP.unquote(results['qsd']['jid'])
# Over-ride the default (and detected) jid
if 'to' in results['qsd'] and len(results['qsd']['to']):
results['targets'] += \
NotifyXMPP.parse_list(results['qsd']['to'])
return results

View file

@ -63,10 +63,11 @@ from ..common import NotifyType
from ..utils import parse_list from ..utils import parse_list
from ..utils import validate_regex from ..utils import validate_regex
from ..utils import is_email from ..utils import is_email
from ..utils import remove_suffix
from ..AppriseLocale import gettext_lazy as _ from ..AppriseLocale import gettext_lazy as _
# A Valid Bot Name # A Valid Bot Name
VALIDATE_BOTNAME = re.compile(r'(?P<name>[A-Z0-9_]{1,32})(-bot)?', re.I) VALIDATE_BOTNAME = re.compile(r'(?P<name>[A-Z0-9_-]{1,32})', re.I)
# Organization required as part of the API request # Organization required as part of the API request
VALIDATE_ORG = re.compile( VALIDATE_ORG = re.compile(
@ -122,7 +123,7 @@ class NotifyZulip(NotifyBase):
'botname': { 'botname': {
'name': _('Bot Name'), 'name': _('Bot Name'),
'type': 'string', 'type': 'string',
'regex': (r'^[A-Z0-9_]{1,32}(-bot)?$', 'i'), 'regex': (r'^[A-Z0-9_-]{1,32}$', 'i'),
}, },
'organization': { 'organization': {
'name': _('Organization'), 'name': _('Organization'),
@ -183,7 +184,9 @@ class NotifyZulip(NotifyBase):
raise TypeError raise TypeError
# The botname # The botname
self.botname = match.group('name') botname = match.group('name')
botname = remove_suffix(botname, '-bot')
self.botname = botname
except (TypeError, AttributeError): except (TypeError, AttributeError):
msg = 'The Zulip botname specified ({}) is invalid.'\ msg = 'The Zulip botname specified ({}) is invalid.'\

View file

@ -24,7 +24,6 @@
# THE SOFTWARE. # THE SOFTWARE.
import os import os
import six
import re import re
import copy import copy
@ -33,7 +32,6 @@ from os.path import abspath
# Used for testing # Used for testing
from . import NotifyEmail as NotifyEmailBase from . import NotifyEmail as NotifyEmailBase
from .NotifyXMPP import SliXmppAdapter
# NotifyBase object is passed in as a module not class # NotifyBase object is passed in as a module not class
from . import NotifyBase from . import NotifyBase
@ -42,6 +40,7 @@ from ..common import NotifyImageSize
from ..common import NOTIFY_IMAGE_SIZES from ..common import NOTIFY_IMAGE_SIZES
from ..common import NotifyType from ..common import NotifyType
from ..common import NOTIFY_TYPES from ..common import NOTIFY_TYPES
from .. import common
from ..utils import parse_list from ..utils import parse_list
from ..utils import cwe312_url from ..utils import cwe312_url
from ..utils import GET_SCHEMA_RE from ..utils import GET_SCHEMA_RE
@ -49,9 +48,6 @@ from ..logger import logger
from ..AppriseLocale import gettext_lazy as _ from ..AppriseLocale import gettext_lazy as _
from ..AppriseLocale import LazyTranslation from ..AppriseLocale import LazyTranslation
# Maintains a mapping of all of the Notification services
SCHEMA_MAP = {}
__all__ = [ __all__ = [
# Reference # Reference
'NotifyImageSize', 'NOTIFY_IMAGE_SIZES', 'NotifyType', 'NOTIFY_TYPES', 'NotifyImageSize', 'NOTIFY_IMAGE_SIZES', 'NotifyType', 'NOTIFY_TYPES',
@ -62,15 +58,8 @@ __all__ = [
# Tokenizer # Tokenizer
'url_to_dict', 'url_to_dict',
# slixmpp access points (used for NotifyXMPP Testing)
'SliXmppAdapter',
] ]
# we mirror our base purely for the ability to reset everything; this
# is generally only used in testing and should not be used by developers
MODULE_MAP = {}
# Load our Lookup Matrix # Load our Lookup Matrix
def __load_matrix(path=abspath(dirname(__file__)), name='apprise.plugins'): def __load_matrix(path=abspath(dirname(__file__)), name='apprise.plugins'):
@ -113,12 +102,12 @@ def __load_matrix(path=abspath(dirname(__file__)), name='apprise.plugins'):
# Filter out non-notification modules # Filter out non-notification modules
continue continue
elif plugin_name in MODULE_MAP: elif plugin_name in common.NOTIFY_MODULE_MAP:
# we're already handling this object # we're already handling this object
continue continue
# Add our plugin name to our module map # Add our plugin name to our module map
MODULE_MAP[plugin_name] = { common.NOTIFY_MODULE_MAP[plugin_name] = {
'plugin': plugin, 'plugin': plugin,
'module': module, 'module': module,
} }
@ -130,40 +119,20 @@ def __load_matrix(path=abspath(dirname(__file__)), name='apprise.plugins'):
globals()[plugin_name] = plugin globals()[plugin_name] = plugin
fn = getattr(plugin, 'schemas', None) fn = getattr(plugin, 'schemas', None)
try: schemas = set([]) if not callable(fn) else fn(plugin)
schemas = set([]) if not callable(fn) else fn(plugin)
except TypeError:
# Python v2.x support where functions associated with classes
# were considered bound to them and could not be called prior
# to the classes initialization. This code can be dropped
# once Python v2.x support is dropped. The below code introduces
# replication as it already exists and is tested in
# URLBase.schemas()
schemas = set([])
for key in ('protocol', 'secure_protocol'):
schema = getattr(plugin, key, None)
if isinstance(schema, six.string_types):
schemas.add(schema)
elif isinstance(schema, (set, list, tuple)):
# Support iterables list types
for s in schema:
if isinstance(s, six.string_types):
schemas.add(s)
# map our schema to our plugin # map our schema to our plugin
for schema in schemas: for schema in schemas:
if schema in SCHEMA_MAP: if schema in common.NOTIFY_SCHEMA_MAP:
logger.error( logger.error(
"Notification schema ({}) mismatch detected - {} to {}" "Notification schema ({}) mismatch detected - {} to {}"
.format(schema, SCHEMA_MAP[schema], plugin)) .format(schema, common.NOTIFY_SCHEMA_MAP[schema], plugin))
continue continue
# Assign plugin # Assign plugin
SCHEMA_MAP[schema] = plugin common.NOTIFY_SCHEMA_MAP[schema] = plugin
return SCHEMA_MAP return common.NOTIFY_SCHEMA_MAP
# Reset our Lookup Matrix # Reset our Lookup Matrix
@ -174,10 +143,10 @@ def __reset_matrix():
""" """
# Reset our schema map # Reset our schema map
SCHEMA_MAP.clear() common.NOTIFY_SCHEMA_MAP.clear()
# Iterate over our module map so we can clear out our __all__ and globals # Iterate over our module map so we can clear out our __all__ and globals
for plugin_name in MODULE_MAP.keys(): for plugin_name in common.NOTIFY_MODULE_MAP.keys():
# Clear out globals # Clear out globals
del globals()[plugin_name] del globals()[plugin_name]
@ -185,7 +154,7 @@ def __reset_matrix():
__all__.remove(plugin_name) __all__.remove(plugin_name)
# Clear out our module map # Clear out our module map
MODULE_MAP.clear() common.NOTIFY_MODULE_MAP.clear()
# Dynamically build our schema base # Dynamically build our schema base
@ -242,7 +211,7 @@ def _sanitize_token(tokens, default_delimiter):
if 'regex' in tokens[key]: if 'regex' in tokens[key]:
# Verify that we are a tuple; convert strings to tuples # Verify that we are a tuple; convert strings to tuples
if isinstance(tokens[key]['regex'], six.string_types): if isinstance(tokens[key]['regex'], str):
# Default tuple setup # Default tuple setup
tokens[key]['regex'] = \ tokens[key]['regex'] = \
(tokens[key]['regex'], None) (tokens[key]['regex'], None)
@ -483,7 +452,7 @@ def requirements(plugin):
# Get our required packages # Get our required packages
_req_packages = plugin.requirements.get('packages_required') _req_packages = plugin.requirements.get('packages_required')
if isinstance(_req_packages, six.string_types): if isinstance(_req_packages, str):
# Convert to list # Convert to list
_req_packages = [_req_packages] _req_packages = [_req_packages]
@ -495,7 +464,7 @@ def requirements(plugin):
# Get our recommended packages # Get our recommended packages
_opt_packages = plugin.requirements.get('packages_recommended') _opt_packages = plugin.requirements.get('packages_recommended')
if isinstance(_opt_packages, six.string_types): if isinstance(_opt_packages, str):
# Convert to list # Convert to list
_opt_packages = [_opt_packages] _opt_packages = [_opt_packages]
@ -554,14 +523,14 @@ def url_to_dict(url, secure_logging=True):
# Ensure our schema is always in lower case # Ensure our schema is always in lower case
schema = schema.group('schema').lower() schema = schema.group('schema').lower()
if schema not in SCHEMA_MAP: if schema not in common.NOTIFY_SCHEMA_MAP:
# Give the user the benefit of the doubt that the user may be using # 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. # one of the URLs provided to them by their notification service.
# Before we fail for good, just scan all the plugins that support the # Before we fail for good, just scan all the plugins that support the
# native_url() parse function # native_url() parse function
results = \ results = \
next((r['plugin'].parse_native_url(_url) next((r['plugin'].parse_native_url(_url)
for r in MODULE_MAP.values() for r in common.NOTIFY_MODULE_MAP.values()
if r['plugin'].parse_native_url(_url) is not None), if r['plugin'].parse_native_url(_url) is not None),
None) None)
@ -576,14 +545,14 @@ def url_to_dict(url, secure_logging=True):
else: else:
# Parse our url details of the server object as dictionary # Parse our url details of the server object as dictionary
# containing all of the information parsed from our URL # containing all of the information parsed from our URL
results = SCHEMA_MAP[schema].parse_url(_url) results = common.NOTIFY_SCHEMA_MAP[schema].parse_url(_url)
if not results: if not results:
logger.error('Unparseable {} URL {}'.format( logger.error('Unparseable {} URL {}'.format(
SCHEMA_MAP[schema].service_name, loggable_url)) common.NOTIFY_SCHEMA_MAP[schema].service_name, loggable_url))
return None return None
logger.trace('{} URL {} unpacked as:{}{}'.format( logger.trace('{} URL {} unpacked as:{}{}'.format(
SCHEMA_MAP[schema].service_name, url, common.NOTIFY_SCHEMA_MAP[schema].service_name, url,
os.linesep, os.linesep.join( os.linesep, os.linesep.join(
['{}="{}"'.format(k, v) for k, v in results.items()]))) ['{}="{}"'.format(k, v) for k, v in results.items()])))

View file

@ -36,9 +36,7 @@ ASYNCIO_RUN_SUPPORT = \
(sys.version_info.major == 3 and sys.version_info.minor >= 7) (sys.version_info.major == 3 and sys.version_info.minor >= 7)
# async reference produces a SyntaxError (E999) in Python v2.7 async def notify(coroutines):
# For this reason we turn on the noqa flag
async def notify(coroutines): # noqa: E999
""" """
An async wrapper to the AsyncNotifyBase.async_notify() calls allowing us An async wrapper to the AsyncNotifyBase.async_notify() calls allowing us
to call gather() and collect the responses to call gather() and collect the responses
@ -63,7 +61,20 @@ def tosync(cor, debug=False):
""" """
if ASYNCIO_RUN_SUPPORT: if ASYNCIO_RUN_SUPPORT:
return asyncio.run(cor, debug=debug) try:
loop = asyncio.get_running_loop()
except RuntimeError:
# There is no existing event loop, so we can start our own.
return asyncio.run(cor, debug=debug)
else:
# Enable debug mode
loop.set_debug(debug)
# Run the coroutine and wait for the result.
task = loop.create_task(cor)
return asyncio.ensure_future(task, loop=loop)
else: else:
# The Deprecated Way (<= Python v3.6) # The Deprecated Way (<= Python v3.6)
@ -85,7 +96,7 @@ def tosync(cor, debug=False):
return loop.run_until_complete(cor) return loop.run_until_complete(cor)
async def toasyncwrap(v): # noqa: E999 async def toasyncwrapvalue(v):
""" """
Create a coroutine that, when run, returns the provided value. Create a coroutine that, when run, returns the provided value.
""" """
@ -93,12 +104,20 @@ async def toasyncwrap(v): # noqa: E999
return v return v
async def toasyncwrap(fn):
"""
Create a coroutine that, when run, executes the provided function.
"""
return fn()
class AsyncNotifyBase(URLBase): class AsyncNotifyBase(URLBase):
""" """
asyncio wrapper for the NotifyBase object asyncio wrapper for the NotifyBase object
""" """
async def async_notify(self, *args, **kwargs): # noqa: E999 async def async_notify(self, *args, **kwargs):
""" """
Async Notification Wrapper Async Notification Wrapper
""" """
@ -110,11 +129,11 @@ class AsyncNotifyBase(URLBase):
None, partial(self.notify, *args, **kwargs)) None, partial(self.notify, *args, **kwargs))
except TypeError: except TypeError:
# These our our internally thrown notifications # These are our internally thrown notifications
pass pass
except Exception: except Exception:
# A catch all so we don't have to abort early # A catch-all so we don't have to abort early
# just because one of our plugins has a bug in it. # just because one of our plugins has a bug in it.
logger.exception("Notification Exception") logger.exception("Notification Exception")

View file

@ -24,27 +24,55 @@
# THE SOFTWARE. # THE SOFTWARE.
import re import re
import six import sys
import json import json
import contextlib import contextlib
import os import os
import hashlib
from itertools import chain from itertools import chain
from os.path import expanduser from os.path import expanduser
from functools import reduce from functools import reduce
from .common import MATCH_ALL_TAG from . import common
from .common import MATCH_ALWAYS_TAG from .logger import logger
try: from urllib.parse import unquote
# Python 2.7 from urllib.parse import quote
from urllib import unquote from urllib.parse import urlparse
from urllib import quote from urllib.parse import urlencode as _urlencode
from urlparse import urlparse
except ImportError: import importlib.util
# Python 3.x
from urllib.parse import unquote
from urllib.parse import quote def import_module(path, name):
from urllib.parse import urlparse """
Load our module based on path
"""
# if path.endswith('test_module_detection0/a/hook.py'):
# import pdb
# pdb.set_trace()
spec = importlib.util.spec_from_file_location(name, path)
try:
module = importlib.util.module_from_spec(spec)
sys.modules[name] = module
spec.loader.exec_module(module)
except Exception as e:
# module isn't loadable
del sys.modules[name]
module = None
logger.debug(
'Custom module exception raised from %s (name=%s) %s',
path, name, str(e))
return module
# Hash of all paths previously scanned so we don't waste effort/overhead doing
# it again
PATHS_PREVIOUSLY_SCANNED = set()
# URL Indexing Table for returns via parse_url() # URL Indexing Table for returns via parse_url()
# The below accepts and scans for: # The below accepts and scans for:
@ -107,6 +135,13 @@ NOTIFY_CUSTOM_COLON_TOKENS = re.compile(r'^:(?P<key>.*)\s*')
# Used for attempting to acquire the schema if the URL can't be parsed. # Used for attempting to acquire the schema if the URL can't be parsed.
GET_SCHEMA_RE = re.compile(r'\s*(?P<schema>[a-z0-9]{2,9})://.*$', re.I) GET_SCHEMA_RE = re.compile(r'\s*(?P<schema>[a-z0-9]{2,9})://.*$', re.I)
# Used for validating that a provided entry is indeed a schema
# this is slightly different then the GET_SCHEMA_RE above which
# insists the schema is only valid with a :// entry. this one
# extrapolates the individual entries
URL_DETAILS_RE = re.compile(
r'\s*(?P<schema>[a-z0-9]{2,9})(://(?P<base>.*))?$', re.I)
# Regular expression based and expanded from: # Regular expression based and expanded from:
# http://www.regular-expressions.info/email.html # http://www.regular-expressions.info/email.html
# Extended to support colon (:) delimiter for parsing names from the URL # Extended to support colon (:) delimiter for parsing names from the URL
@ -167,7 +202,7 @@ UUID4_RE = re.compile(
REGEX_VALIDATE_LOOKUP = {} REGEX_VALIDATE_LOOKUP = {}
class TemplateType(object): class TemplateType:
""" """
Defines the different template types we can perform parsing on Defines the different template types we can perform parsing on
""" """
@ -294,7 +329,7 @@ def is_uuid(uuid):
return True if match else False return True if match else False
def is_phone_no(phone, min_len=11): def is_phone_no(phone, min_len=10):
"""Determine if the specified entry is a phone number """Determine if the specified entry is a phone number
Args: Args:
@ -477,16 +512,14 @@ def tidy_path(path):
# Windows # Windows
path = TIDY_WIN_PATH_RE.sub('\\1', path.strip()) path = TIDY_WIN_PATH_RE.sub('\\1', path.strip())
# Linux # Linux
path = TIDY_NUX_PATH_RE.sub('\\1', path.strip()) path = TIDY_NUX_PATH_RE.sub('\\1', path)
# Linux Based Trim # Windows Based (final) Trim
path = TIDY_NUX_TRIM_RE.sub('\\1', path.strip()) path = expanduser(TIDY_WIN_TRIM_RE.sub('\\1', path))
# Windows Based Trim
path = expanduser(TIDY_WIN_TRIM_RE.sub('\\1', path.strip()))
return path return path
def parse_qsd(qs): def parse_qsd(qs, simple=False):
""" """
Query String Dictionary Builder Query String Dictionary Builder
@ -505,6 +538,9 @@ def parse_qsd(qs):
This function returns a result object that fits with the apprise This function returns a result object that fits with the apprise
expected parameters (populating the 'qsd' portion of the dictionary expected parameters (populating the 'qsd' portion of the dictionary
if simple is set to true, then a ONE dictionary is returned and is not
sub-parsed for additional elements
""" """
# Our return result set: # Our return result set:
@ -520,7 +556,7 @@ def parse_qsd(qs):
'qsd+': {}, 'qsd+': {},
'qsd-': {}, 'qsd-': {},
'qsd:': {}, 'qsd:': {},
} } if not simple else {'qsd': {}}
pairs = [s2 for s1 in qs.split('&') for s2 in s1.split(';')] pairs = [s2 for s1 in qs.split('&') for s2 in s1.split(';')]
for name_value in pairs: for name_value in pairs:
@ -547,6 +583,10 @@ def parse_qsd(qs):
# content is always made lowercase for easy indexing # content is always made lowercase for easy indexing
result['qsd'][key.lower().strip()] = val result['qsd'][key.lower().strip()] = val
if simple:
# move along
continue
# Check for tokens that start with a addition/plus symbol (+) # Check for tokens that start with a addition/plus symbol (+)
k = NOTIFY_CUSTOM_ADD_TOKENS.match(key) k = NOTIFY_CUSTOM_ADD_TOKENS.match(key)
if k is not None: if k is not None:
@ -568,7 +608,8 @@ def parse_qsd(qs):
return result return result
def parse_url(url, default_schema='http', verify_host=True, strict_port=False): def parse_url(url, default_schema='http', verify_host=True, strict_port=False,
simple=False):
"""A function that greatly simplifies the parsing of a url """A function that greatly simplifies the parsing of a url
specified by the end user. specified by the end user.
@ -590,9 +631,42 @@ def parse_url(url, default_schema='http', verify_host=True, strict_port=False):
The function returns a simple dictionary with all of The function returns a simple dictionary with all of
the parsed content within it and returns 'None' if the the parsed content within it and returns 'None' if the
content could not be extracted. content could not be extracted.
The output of 'http://hostname' would look like:
{
'schema': 'http',
'url': 'http://hostname',
'host': 'hostname',
'user': None,
'password': None,
'port': None,
'fullpath': None,
'path': None,
'query': None,
'qsd': {},
'qsd+': {},
'qsd-': {},
'qsd:': {}
}
The simple switch cleans the dictionary response to only include the
fields that were detected.
The output of 'http://hostname' with the simple flag set would look like:
{
'schema': 'http',
'url': 'http://hostname',
'host': 'hostname',
}
If the URL can't be parsed then None is returned
""" """
if not isinstance(url, six.string_types): if not isinstance(url, str):
# Simple error checking # Simple error checking
return None return None
@ -628,7 +702,7 @@ def parse_url(url, default_schema='http', verify_host=True, strict_port=False):
'qsd+': {}, 'qsd+': {},
'qsd-': {}, 'qsd-': {},
'qsd:': {}, 'qsd:': {},
} } if not simple else {}
qsdata = '' qsdata = ''
match = VALID_URL_RE.search(url) match = VALID_URL_RE.search(url)
@ -648,7 +722,7 @@ def parse_url(url, default_schema='http', verify_host=True, strict_port=False):
# Parse Query Arugments ?val=key&key=val # Parse Query Arugments ?val=key&key=val
# while ensuring that all keys are lowercase # while ensuring that all keys are lowercase
if qsdata: if qsdata:
result.update(parse_qsd(qsdata)) result.update(parse_qsd(qsdata, simple=simple))
# Now do a proper extraction of data; http:// is just substitued in place # Now do a proper extraction of data; http:// is just substitued in place
# to allow urlparse() to function as expected, we'll swap this back to the # to allow urlparse() to function as expected, we'll swap this back to the
@ -671,8 +745,12 @@ def parse_url(url, default_schema='http', verify_host=True, strict_port=False):
pass pass
if not result['fullpath']: if not result['fullpath']:
# Default if not simple:
result['fullpath'] = None # Default
result['fullpath'] = None
else:
# Remove entry
del result['fullpath']
else: else:
# Using full path, extract query from path # Using full path, extract query from path
@ -680,7 +758,11 @@ def parse_url(url, default_schema='http', verify_host=True, strict_port=False):
result['path'] = match.group('path') result['path'] = match.group('path')
result['query'] = match.group('query') result['query'] = match.group('query')
if not result['query']: if not result['query']:
result['query'] = None if not simple:
result['query'] = None
else:
del result['query']
try: try:
(result['user'], result['host']) = \ (result['user'], result['host']) = \
re.split(r'[@]+', result['host'])[:2] re.split(r'[@]+', result['host'])[:2]
@ -690,7 +772,7 @@ def parse_url(url, default_schema='http', verify_host=True, strict_port=False):
# and it's already assigned # and it's already assigned
pass pass
if result['user'] is not None: if result.get('user') is not None:
try: try:
(result['user'], result['password']) = \ (result['user'], result['password']) = \
re.split(r'[:]+', result['user'])[:2] re.split(r'[:]+', result['user'])[:2]
@ -724,6 +806,9 @@ def parse_url(url, default_schema='http', verify_host=True, strict_port=False):
# Invalid Host Specified # Invalid Host Specified
return None return None
# Acquire our port (if defined)
_port = result.get('port')
if verify_host: if verify_host:
# Verify and Validate our hostname # Verify and Validate our hostname
result['host'] = is_hostname(result['host']) result['host'] = is_hostname(result['host'])
@ -733,15 +818,14 @@ def parse_url(url, default_schema='http', verify_host=True, strict_port=False):
return None return None
# Max port is 65535 and min is 1 # Max port is 65535 and min is 1
if isinstance(result['port'], int) and not (( if isinstance(_port, int) and not ((
not strict_port or ( not strict_port or (
strict_port and strict_port and _port > 0 and _port <= 65535))):
result['port'] > 0 and result['port'] <= 65535))):
# An invalid port was specified # An invalid port was specified
return None return None
elif pmatch and not isinstance(result['port'], int): elif pmatch and not isinstance(_port, int):
if strict_port: if strict_port:
# Store port # Store port
result['port'] = pmatch.group('port').strip() result['port'] = pmatch.group('port').strip()
@ -754,26 +838,34 @@ def parse_url(url, default_schema='http', verify_host=True, strict_port=False):
# Re-assemble cleaned up version of the url # Re-assemble cleaned up version of the url
result['url'] = '%s://' % result['schema'] result['url'] = '%s://' % result['schema']
if isinstance(result['user'], six.string_types): if isinstance(result.get('user'), str):
result['url'] += result['user'] result['url'] += result['user']
if isinstance(result['password'], six.string_types): if isinstance(result.get('password'), str):
result['url'] += ':%s@' % result['password'] result['url'] += ':%s@' % result['password']
else: else:
result['url'] += '@' result['url'] += '@'
result['url'] += result['host'] result['url'] += result['host']
if result['port'] is not None: if result.get('port') is not None:
try: try:
result['url'] += ':%d' % result['port'] result['url'] += ':%d' % result['port']
except TypeError: except TypeError:
result['url'] += ':%s' % result['port'] result['url'] += ':%s' % result['port']
if result['fullpath']: elif 'port' in result and simple:
# Eliminate empty fields
del result['port']
if result.get('fullpath'):
result['url'] += result['fullpath'] result['url'] += result['fullpath']
if simple and not result['host']:
# simple mode does not carry over empty host names
del result['host']
return result return result
@ -784,7 +876,7 @@ def parse_bool(arg, default=False):
If the content could not be parsed, then the default is returned. If the content could not be parsed, then the default is returned.
""" """
if isinstance(arg, six.string_types): if isinstance(arg, str):
# no = no - False # no = no - False
# of = short for off - False # of = short for off - False
# 0 = int for False # 0 = int for False
@ -814,20 +906,15 @@ def parse_bool(arg, default=False):
return bool(arg) return bool(arg)
def parse_phone_no(*args, **kwargs): def parse_phone_no(*args, store_unparseable=True, **kwargs):
""" """
Takes a string containing phone numbers separated by comma's and/or spaces Takes a string containing phone numbers separated by comma's and/or spaces
and returns a list. and returns a list.
""" """
# for Python 2.7 support, store_unparsable is not in the url above
# as just parse_emails(*args, store_unparseable=True) since it is
# an invalid syntax. This is the workaround to be backards compatible:
store_unparseable = kwargs.get('store_unparseable', True)
result = [] result = []
for arg in args: for arg in args:
if isinstance(arg, six.string_types) and arg: if isinstance(arg, str) and arg:
_result = PHONE_NO_DETECTION_RE.findall(arg) _result = PHONE_NO_DETECTION_RE.findall(arg)
if _result: if _result:
result += _result result += _result
@ -851,20 +938,15 @@ def parse_phone_no(*args, **kwargs):
return result return result
def parse_call_sign(*args, **kwargs): def parse_call_sign(*args, store_unparseable=True, **kwargs):
""" """
Takes a string containing ham radio call signs separated by Takes a string containing ham radio call signs separated by
comma and/or spacesand returns a list. comma and/or spacesand returns a list.
""" """
# for Python 2.7 support, store_unparsable is not in the url above
# as just parse_emails(*args, store_unparseable=True) since it is
# an invalid syntax. This is the workaround to be backards compatible:
store_unparseable = kwargs.get('store_unparseable', True)
result = [] result = []
for arg in args: for arg in args:
if isinstance(arg, six.string_types) and arg: if isinstance(arg, str) and arg:
_result = CALL_SIGN_DETECTION_RE.findall(arg) _result = CALL_SIGN_DETECTION_RE.findall(arg)
if _result: if _result:
result += _result result += _result
@ -888,20 +970,15 @@ def parse_call_sign(*args, **kwargs):
return result return result
def parse_emails(*args, **kwargs): def parse_emails(*args, store_unparseable=True, **kwargs):
""" """
Takes a string containing emails separated by comma's and/or spaces and Takes a string containing emails separated by comma's and/or spaces and
returns a list. returns a list.
""" """
# for Python 2.7 support, store_unparsable is not in the url above
# as just parse_emails(*args, store_unparseable=True) since it is
# an invalid syntax. This is the workaround to be backards compatible:
store_unparseable = kwargs.get('store_unparseable', True)
result = [] result = []
for arg in args: for arg in args:
if isinstance(arg, six.string_types) and arg: if isinstance(arg, str) and arg:
_result = EMAIL_DETECTION_RE.findall(arg) _result = EMAIL_DETECTION_RE.findall(arg)
if _result: if _result:
result += _result result += _result
@ -924,20 +1001,15 @@ def parse_emails(*args, **kwargs):
return result return result
def parse_urls(*args, **kwargs): def parse_urls(*args, store_unparseable=True, **kwargs):
""" """
Takes a string containing URLs separated by comma's and/or spaces and Takes a string containing URLs separated by comma's and/or spaces and
returns a list. returns a list.
""" """
# for Python 2.7 support, store_unparsable is not in the url above
# as just parse_urls(*args, store_unparseable=True) since it is
# an invalid syntax. This is the workaround to be backards compatible:
store_unparseable = kwargs.get('store_unparseable', True)
result = [] result = []
for arg in args: for arg in args:
if isinstance(arg, six.string_types) and arg: if isinstance(arg, str) and arg:
_result = URL_DETECTION_RE.findall(arg) _result = URL_DETECTION_RE.findall(arg)
if _result: if _result:
result += _result result += _result
@ -960,6 +1032,75 @@ def parse_urls(*args, **kwargs):
return result return result
def url_assembly(**kwargs):
"""
This function reverses the parse_url() function by taking in the provided
result set and re-assembling a URL
"""
# Determine Authentication
auth = ''
if kwargs.get('user') is not None and \
kwargs.get('password') is not None:
auth = '{user}:{password}@'.format(
user=quote(kwargs.get('user'), safe=''),
password=quote(kwargs.get('password'), safe=''),
)
elif kwargs.get('user') is not None:
auth = '{user}@'.format(
user=quote(kwargs.get('user'), safe=''),
)
return '{schema}://{auth}{hostname}{port}{fullpath}{params}'.format(
schema='' if not kwargs.get('schema') else kwargs.get('schema'),
auth=auth,
# never encode hostname since we're expecting it to be a valid one
hostname='' if not kwargs.get('host') else kwargs.get('host', ''),
port='' if not kwargs.get('port')
else ':{}'.format(kwargs.get('port')),
fullpath=quote(kwargs.get('fullpath', ''), safe='/'),
params='' if not kwargs.get('qsd')
else '?{}'.format(urlencode(kwargs.get('qsd'))),
)
def urlencode(query, doseq=False, safe='', encoding=None, errors=None):
"""Convert a mapping object or a sequence of two-element tuples
Wrapper to Python's unquote while remaining compatible with both
Python 2 & 3 since the reference to this function changed between
versions.
The resulting string is a series of key=value pairs separated by '&'
characters, where both key and value are quoted using the quote()
function.
Note: If the dictionary entry contains an entry that is set to None
it is not included in the final result set. If you want to
pass in an empty variable, set it to an empty string.
Args:
query (str): The dictionary to encode
doseq (:obj:`bool`, optional): Handle sequences
safe (:obj:`str`): non-ascii characters and URI specific ones that
you do not wish to escape (if detected). Setting this string
to an empty one causes everything to be escaped.
encoding (:obj:`str`, optional): encoding type
errors (:obj:`str`, errors): how to handle invalid character found
in encoded string (defined by encoding)
Returns:
str: The escaped parameters returned as a string
"""
# Tidy query by eliminating any records set to None
_query = {k: v for (k, v) in query.items() if v is not None}
return _urlencode(
_query, doseq=doseq, safe=safe, encoding=encoding,
errors=errors)
def parse_list(*args): def parse_list(*args):
""" """
Take a string list and break it into a delimited Take a string list and break it into a delimited
@ -983,7 +1124,7 @@ def parse_list(*args):
result = [] result = []
for arg in args: for arg in args:
if isinstance(arg, six.string_types): if isinstance(arg, str):
result += re.split(STRING_DELIMITERS, arg) result += re.split(STRING_DELIMITERS, arg)
elif isinstance(arg, (set, list, tuple)): elif isinstance(arg, (set, list, tuple)):
@ -992,14 +1133,15 @@ def parse_list(*args):
# #
# filter() eliminates any empty entries # filter() eliminates any empty entries
# #
# Since Python v3 returns a filter (iterator) where-as Python v2 returned # Since Python v3 returns a filter (iterator) whereas Python v2 returned
# a list, we need to change it into a list object to remain compatible with # a list, we need to change it into a list object to remain compatible with
# both distribution types. # both distribution types.
# TODO: Review after dropping support for Python 2.
return sorted([x for x in filter(bool, list(set(result)))]) return sorted([x for x in filter(bool, list(set(result)))])
def is_exclusive_match(logic, data, match_all=MATCH_ALL_TAG, def is_exclusive_match(logic, data, match_all=common.MATCH_ALL_TAG,
match_always=MATCH_ALWAYS_TAG): match_always=common.MATCH_ALWAYS_TAG):
""" """
The data variable should always be a set of strings that the logic can be The data variable should always be a set of strings that the logic can be
@ -1020,7 +1162,7 @@ def is_exclusive_match(logic, data, match_all=MATCH_ALL_TAG,
to all specified logic searches. to all specified logic searches.
""" """
if isinstance(logic, six.string_types): if isinstance(logic, str):
# Update our logic to support our delimiters # Update our logic to support our delimiters
logic = set(parse_list(logic)) logic = set(parse_list(logic))
@ -1043,7 +1185,7 @@ def is_exclusive_match(logic, data, match_all=MATCH_ALL_TAG,
# Every entry here will be or'ed with the next # Every entry here will be or'ed with the next
for entry in logic: for entry in logic:
if not isinstance(entry, (six.string_types, list, tuple, set)): if not isinstance(entry, (str, list, tuple, set)):
# Garbage entry in our logic found # Garbage entry in our logic found
return False return False
@ -1109,7 +1251,7 @@ def validate_regex(value, regex=r'[^\s]+', flags=re.I, strip=True, fmt=None):
'x': re.X, 'x': re.X,
} }
if isinstance(flags, six.string_types): if isinstance(flags, str):
# Convert a string of regular expression flags into their # Convert a string of regular expression flags into their
# respected integer (expected) Python values and perform # respected integer (expected) Python values and perform
# a bit-wise or on each match found: # a bit-wise or on each match found:
@ -1164,7 +1306,7 @@ def cwe312_word(word, force=False, advanced=True, threshold=5):
reached, then content is considered secret reached, then content is considered secret
""" """
class Variance(object): class Variance:
""" """
A Simple List of Possible Character Variances A Simple List of Possible Character Variances
""" """
@ -1177,7 +1319,7 @@ def cwe312_word(word, force=False, advanced=True, threshold=5):
# A Numerical Character (1234... etc) # A Numerical Character (1234... etc)
NUMERIC = 'n' NUMERIC = 'n'
if not (isinstance(word, six.string_types) and word.strip()): if not (isinstance(word, str) and word.strip()):
# not a password if it's not something we even support # not a password if it's not something we even support
return word return word
@ -1379,3 +1521,161 @@ def apply_template(template, app_mode=TemplateType.RAW, **kwargs):
# to drop the '{{' and '}}' surrounding our match so that we can # to drop the '{{' and '}}' surrounding our match so that we can
# re-index it back into our list # re-index it back into our list
return mask_r.sub(lambda x: fn(kwargs[x.group()[2:-2].strip()]), template) return mask_r.sub(lambda x: fn(kwargs[x.group()[2:-2].strip()]), template)
def remove_suffix(value, suffix):
"""
Removes a suffix from the end of a string.
"""
return value[:-len(suffix)] if value.endswith(suffix) else value
def module_detection(paths, cache=True):
"""
Iterates over a defined path for apprise decorators to load such as
@notify.
"""
# A simple restriction that we don't allow periods in the filename at all
# so it can't be hidden (Linux OS's) and it won't conflict with Python
# path naming. This also prevents us from loading any python file that
# starts with an underscore or dash
# We allow __init__.py as well
module_re = re.compile(
r'^(?P<name>[_a-z0-9][a-z0-9._-]+)?(\.py)?$', re.I)
if isinstance(paths, str):
paths = [paths, ]
if not paths or not isinstance(paths, (tuple, list)):
# We're done
return None
def _import_module(path):
# Since our plugin name can conflict (as a module) with another
# we want to generate random strings to avoid steping on
# another's namespace
module_name = hashlib.sha1(path.encode('utf-8')).hexdigest()
module_pyname = "{prefix}.{name}".format(
prefix='apprise.custom.module', name=module_name)
if module_pyname in common.NOTIFY_CUSTOM_MODULE_MAP:
# First clear out existing entries
for schema in common.\
NOTIFY_CUSTOM_MODULE_MAP[module_pyname]['notify'] \
.keys():
# Remove any mapped modules to this file
del common.NOTIFY_SCHEMA_MAP[schema]
# Reset
del common.NOTIFY_CUSTOM_MODULE_MAP[module_pyname]
# Load our module
module = import_module(path, module_pyname)
if not module:
# No problem, we can't use this object
logger.warning('Failed to load custom module: %s', _path)
return None
# Print our loaded modules if any
if module_pyname in common.NOTIFY_CUSTOM_MODULE_MAP:
logger.debug(
'Loaded custom module: %s (name=%s)',
_path, module_name)
for schema, meta in common.\
NOTIFY_CUSTOM_MODULE_MAP[module_pyname]['notify']\
.items():
logger.info('Loaded custom notification: %s://', schema)
else:
# The code reaches here if we successfully loaded the Python
# module but no hooks/triggers were found. So we can safely
# just remove/ignore this entry
del sys.modules[module_pyname]
return None
# end of _import_module()
return None
for _path in paths:
path = os.path.abspath(os.path.expanduser(_path))
if (cache and path in PATHS_PREVIOUSLY_SCANNED) \
or not os.path.exists(path):
# We're done as we've already scanned this
continue
# Store our path as a way of hashing it has been handled
PATHS_PREVIOUSLY_SCANNED.add(path)
if os.path.isdir(path) and not \
os.path.isfile(os.path.join(path, '__init__.py')):
logger.debug('Scanning for custom plugins in: %s', path)
for entry in os.listdir(path):
re_match = module_re.match(entry)
if not re_match:
# keep going
logger.trace('Plugin Scan: Ignoring %s', entry)
continue
new_path = os.path.join(path, entry)
if os.path.isdir(new_path):
# Update our path
new_path = os.path.join(path, entry, '__init__.py')
if not os.path.isfile(new_path):
logger.trace(
'Plugin Scan: Ignoring %s',
os.path.join(path, entry))
continue
if not cache or \
(cache and new_path not in PATHS_PREVIOUSLY_SCANNED):
# Load our module
_import_module(new_path)
# Add our subdir path
PATHS_PREVIOUSLY_SCANNED.add(new_path)
else:
if os.path.isdir(path):
# This logic is safe to apply because we already validated
# the directories state above; update our path
path = os.path.join(path, '__init__.py')
if cache and path in PATHS_PREVIOUSLY_SCANNED:
continue
PATHS_PREVIOUSLY_SCANNED.add(path)
# directly load as is
re_match = module_re.match(os.path.basename(path))
# must be a match and must have a .py extension
if not re_match or not re_match.group(1):
# keep going
logger.trace('Plugin Scan: Ignoring %s', path)
continue
# Load our module
_import_module(path)
return None
def dict_full_update(dict1, dict2):
"""
Takes 2 dictionaries (dict1 and dict2) that contain sub-dictionaries and
gracefully merges them into dict1.
This is similar to: dict1.update(dict2) except that internal dictionaries
are also recursively applied.
"""
def _merge(dict1, dict2):
for k in dict2:
if k in dict1 and isinstance(dict1[k], dict) \
and isinstance(dict2[k], dict):
_merge(dict1[k], dict2[k])
else:
dict1[k] = dict2[k]
_merge(dict1, dict2)
return

View file

@ -1,7 +1,7 @@
# Bazarr dependencies # Bazarr dependencies
aniso8601==9.0.1 aniso8601==9.0.1
argparse==1.4.0 argparse==1.4.0
apprise==0.9.8.3 apprise==1.1.0
apscheduler==3.8.1 apscheduler==3.8.1
attrs==22.1.0 attrs==22.1.0
charamel==1.0.0 charamel==1.0.0