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.
import os
import six
from itertools import chain
from .common import NotifyType
from .common import MATCH_ALL_TAG
from .common import MATCH_ALWAYS_TAG
from . import common
from .conversion import convert_between
from .utils import is_exclusive_match
from .utils import parse_list
from .utils import parse_urls
from .utils import cwe312_url
from .logger import logger
from .AppriseAsset import AppriseAsset
from .AppriseConfig import AppriseConfig
from .AppriseAttachment import AppriseAttachment
@ -47,13 +43,13 @@ from .plugins.NotifyBase import NotifyBase
from . import plugins
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
# TODO: Review after dropping support for Python 2.
from . import py3compat
ASYNCIO_SUPPORT = not six.PY2
class Apprise(object):
class Apprise:
"""
Our Notification Manager
@ -127,7 +123,7 @@ class Apprise(object):
# Prepare our Asset Object
asset = asset if isinstance(asset, AppriseAsset) else AppriseAsset()
if isinstance(url, six.string_types):
if isinstance(url, str):
# Acquire our url tokens
results = plugins.url_to_dict(
url, secure_logging=asset.secure_logging)
@ -141,7 +137,7 @@ class Apprise(object):
# We already have our result set
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
# we can index into our loaded plugins
logger.error('Dictionary does not include a "schema" entry.')
@ -164,7 +160,7 @@ class Apprise(object):
type(url))
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)
#
@ -184,12 +180,13 @@ class Apprise(object):
try:
# Attempt to create an instance of our plugin using the parsed
# URL information
plugin = plugins.SCHEMA_MAP[results['schema']](**results)
plugin = common.NOTIFY_SCHEMA_MAP[results['schema']](**results)
# Create log entry of loaded URL
logger.debug(
'Loaded {} URL: {}'.format(
plugins.SCHEMA_MAP[results['schema']].service_name,
common.
NOTIFY_SCHEMA_MAP[results['schema']].service_name,
plugin.url(privacy=asset.secure_logging)))
except Exception:
@ -200,14 +197,15 @@ class Apprise(object):
# the arguments are invalid or can not be used.
logger.error(
'Could not load {} URL: {}'.format(
plugins.SCHEMA_MAP[results['schema']].service_name,
common.
NOTIFY_SCHEMA_MAP[results['schema']].service_name,
loggable_url))
return None
else:
# Attempt to create an instance of our plugin using the parsed
# 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:
#
@ -248,7 +246,7 @@ class Apprise(object):
# prepare default asset
asset = self.asset
if isinstance(servers, six.string_types):
if isinstance(servers, str):
# build our server list
servers = parse_urls(servers)
if len(servers) == 0:
@ -276,7 +274,7 @@ class Apprise(object):
self.servers.append(_server)
continue
elif not isinstance(_server, (six.string_types, dict)):
elif not isinstance(_server, (str, dict)):
logger.error(
"An invalid notification (type={}) was specified.".format(
type(_server)))
@ -305,9 +303,9 @@ class Apprise(object):
"""
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
# 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
for entry in self.servers:
@ -338,23 +336,24 @@ class Apprise(object):
for server in servers:
# Apply our tag matching based on our defined logic
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):
yield server
return
def notify(self, body, title='', notify_type=NotifyType.INFO,
body_format=None, tag=MATCH_ALL_TAG, match_always=True,
def notify(self, body, title='', notify_type=common.NotifyType.INFO,
body_format=None, tag=common.MATCH_ALL_TAG, match_always=True,
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
be converted to HTML if the Notification type expects this.
if the tag is specified (either a string or a set/list/tuple
of strings), then only the notifications flagged with that
tagged value are notified. By default all added services
tagged value are notified. By default, all added services
are notified (tag=MATCH_ALL_TAG)
This function returns True if all notifications were successfully
@ -363,60 +362,33 @@ class Apprise(object):
simply having empty configuration files that were read.
Attach can contain a list of attachment URLs. attach can also be
represented by a an AttachBase() (or list of) object(s). This
represented by an AttachBase() (or list of) object(s). This
identifies the products you wish to notify
Set interpret_escapes to True if you want to pre-escape a string
such as turning a \n into an actual new line, etc.
"""
if ASYNCIO_SUPPORT:
return py3compat.asyncio.tosync(
self.async_notify(
body, title,
notify_type=notify_type, body_format=body_format,
tag=tag, match_always=match_always, attach=attach,
interpret_escapes=interpret_escapes,
),
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
return py3compat.asyncio.tosync(
self.async_notify(
body, title,
notify_type=notify_type, body_format=body_format,
tag=tag, match_always=match_always, attach=attach,
interpret_escapes=interpret_escapes,
),
debug=self.debug
)
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
awaited on, even if it is missing the async keyword in its signature.
(This is omitted to preserve syntax compatibility with Python 2.)
The arguments are identical to those of Apprise.notify(). This method
is not available in Python 2.
"""
The arguments are identical to those of Apprise.notify().
"""
try:
coroutines = list(
self._notifyall(
@ -424,7 +396,7 @@ class Apprise(object):
except TypeError:
# No notifications sent, and there was an internal error.
return py3compat.asyncio.toasyncwrap(False)
return py3compat.asyncio.toasyncwrapvalue(False)
else:
if len(coroutines) > 0:
@ -433,7 +405,7 @@ class Apprise(object):
else:
# No notifications sent.
return py3compat.asyncio.toasyncwrap(None)
return py3compat.asyncio.toasyncwrapvalue(None)
@staticmethod
def _notifyhandler(server, **kwargs):
@ -470,13 +442,14 @@ class Apprise(object):
# Send the notification immediately, and wrap the result in a
# coroutine.
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,
body_format=None, tag=MATCH_ALL_TAG, match_always=True,
attach=None, interpret_escapes=None):
def _notifyall(self, handler, body, title='',
notify_type=common.NotifyType.INFO, body_format=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
and only argument supplied to handler is the server, and the keyword
@ -485,7 +458,7 @@ class Apprise(object):
if len(self) == 0:
# Nothing to notify
msg = "There are service(s) to notify"
msg = "There are no service(s) to notify"
logger.error(msg)
raise TypeError(msg)
@ -495,23 +468,11 @@ class Apprise(object):
raise TypeError(msg)
try:
if six.PY2:
# Python 2.7 encoding support isn't the greatest, so we try
# 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 title and isinstance(title, bytes):
title = title.decode(self.asset.encoding)
if body and isinstance(body, str): # noqa: F821
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)
if body and isinstance(body, bytes):
body = body.decode(self.asset.encoding)
except UnicodeDecodeError:
msg = 'The content passed into Apprise was not of encoding ' \
@ -579,43 +540,12 @@ class Apprise(object):
.encode('ascii', 'backslashreplace')\
.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:
# Must be of string type
msg = 'Failed to escape message body'
logger.error(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(
server,
body=conversion_body_map[server.notify_format],
@ -641,7 +571,7 @@ class Apprise(object):
'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
# their status:
@ -650,7 +580,10 @@ class Apprise(object):
'service_url': getattr(plugin, 'service_url', None),
'setup_url': getattr(plugin, 'setup_url', None),
# 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
@ -665,12 +598,12 @@ class Apprise(object):
# Standard protocol(s) should be None or a tuple
protocols = getattr(plugin, 'protocol', None)
if isinstance(protocols, six.string_types):
if isinstance(protocols, str):
protocols = (protocols, )
# Secure protocol(s) should be None or a tuple
secure_protocols = getattr(plugin, 'secure_protocol', None)
if isinstance(secure_protocols, six.string_types):
if isinstance(secure_protocols, str):
secure_protocols = (secure_protocols, )
# Add our protocol details to our content
@ -775,15 +708,8 @@ class Apprise(object):
def __bool__(self):
"""
Allows the Apprise object to be wrapped in an Python 3.x based 'if
statement'. True is returned if at least one service has been loaded.
"""
return len(self) > 0
def __nonzero__(self):
"""
Allows the Apprise object to be wrapped in an Python 2.x based 'if
statement'. True is returned if at least one service has been loaded.
Allows the Apprise object to be wrapped in an 'if statement'.
True is returned if at least one service has been loaded.
"""
return len(self) > 0
@ -803,7 +729,3 @@ class Apprise(object):
"""
return sum([1 if not isinstance(s, (ConfigBase, AppriseConfig))
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 __getitem__(self, index: int) -> ConfigBase: ...
def __bool__(self) -> bool: ...
def __nonzero__(self) -> bool: ...
def __iter__(self) -> Iterator[ConfigBase]: ...
def __len__(self) -> int: ...

View file

@ -30,15 +30,20 @@ from os.path import dirname
from os.path import isfile
from os.path import abspath
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
information and details that can be used by Apprise such as providing
an alternate location to where images/icons can be found and the
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
app_id = 'Apprise'
@ -102,8 +107,8 @@ class AppriseAsset(object):
# - NotifyFormat.HTML
# - None
#
# If no format is specified (hence None), then no special pre-formating
# actions will take place during a notificaton. This has been and always
# If no format is specified (hence None), then no special pre-formatting
# actions will take place during a notification. This has been and always
# will be the default.
body_format = None
@ -132,6 +137,10 @@ class AppriseAsset(object):
# that you leave this option as is otherwise.
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 (_)
# These can only be initialized using Python libraries and are not picked
# 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
_uid = str(uuid4())
def __init__(self, **kwargs):
def __init__(self, plugin_paths=None, **kwargs):
"""
Asset Initialization
@ -160,6 +169,10 @@ class AppriseAsset(object):
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):
"""
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
# THE SOFTWARE.
import six
from . import attachment
from . import URLBase
from .AppriseAsset import AppriseAsset
from .logger import logger
from .common import ContentLocation
from .common import CONTENT_LOCATIONS
from .common import ATTACHMENT_SCHEMA_MAP
from .utils import GET_SCHEMA_RE
class AppriseAttachment(object):
class AppriseAttachment:
"""
Our Apprise Attachment File Manager
@ -142,7 +141,7 @@ class AppriseAttachment(object):
self.attachments.append(attachments)
return True
elif isinstance(attachments, six.string_types):
elif isinstance(attachments, str):
# Save our path
attachments = (attachments, )
@ -161,7 +160,7 @@ class AppriseAttachment(object):
return_status = False
continue
if isinstance(_attachment, six.string_types):
if isinstance(_attachment, str):
logger.debug("Loading attachment: {}".format(_attachment))
# Instantiate ourselves an object, this function throws or
# returns None if it fails
@ -225,13 +224,13 @@ class AppriseAttachment(object):
schema = schema.group('schema').lower()
# Some basic validation
if schema not in attachment.SCHEMA_MAP:
if schema not in ATTACHMENT_SCHEMA_MAP:
logger.warning('Unsupported schema {}.'.format(schema))
return None
# Parse our url details of the server object as dictionary containing
# 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:
# Failed to parse the server URL
@ -251,7 +250,7 @@ class AppriseAttachment(object):
# Attempt to create an instance of our plugin using the parsed
# URL information
attach_plugin = \
attachment.SCHEMA_MAP[results['schema']](**results)
ATTACHMENT_SCHEMA_MAP[results['schema']](**results)
except Exception:
# the arguments are invalid or can not be used.
@ -261,7 +260,7 @@ class AppriseAttachment(object):
else:
# Attempt to create an instance of our plugin using the parsed
# 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
@ -295,15 +294,8 @@ class AppriseAttachment(object):
def __bool__(self):
"""
Allows the Apprise object to be wrapped in an Python 3.x based 'if
statement'. True is returned if at least one service has been loaded.
"""
return True if self.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.
Allows the Apprise object to be wrapped in an 'if statement'.
True is returned if at least one service has been loaded.
"""
return True if self.attachments else False

View file

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

View file

@ -44,6 +44,5 @@ class AppriseConfig:
def pop(self, index: int = ...) -> ConfigBase: ...
def __getitem__(self, index: int) -> ConfigBase: ...
def __bool__(self) -> bool: ...
def __nonzero__(self) -> bool: ...
def __iter__(self) -> Iterator[ConfigBase]: ...
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
# THE SOFTWARE.
import six
import ctypes
import locale
import contextlib
@ -52,18 +51,11 @@ try:
except ImportError:
# gettext isn't available; no problem, just fall back to using
# the library features without multi-language support.
try:
# Python v2.7
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
import builtins
builtins.__dict__['_'] = lambda x: x # pragma: no branch
class LazyTranslation(object):
class LazyTranslation:
"""
Doesn't translate anything until str() or unicode() references
are made.
@ -89,7 +81,7 @@ def gettext_lazy(text):
return LazyTranslation(text=text)
class AppriseLocale(object):
class AppriseLocale:
"""
A wrapper class to gettext so that we can manipulate multiple lanaguages
on the fly if required.
@ -186,7 +178,7 @@ class AppriseLocale(object):
"""
# We want to only use the 2 character version of this language
# 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:
# no detection enabled; we're done
return None

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -26,12 +26,12 @@
import click
import logging
import platform
import six
import sys
import os
import re
from os.path import isfile
from os.path import exists
from os.path import expanduser
from os.path import expandvars
@ -40,6 +40,7 @@ from . import NotifyFormat
from . import Apprise
from . import AppriseAsset
from . import AppriseConfig
from .utils import parse_list
from .common import NOTIFY_TYPES
from .common import NOTIFY_FORMATS
@ -60,23 +61,42 @@ DEFAULT_RECURSION_DEPTH = 1
CONTEXT_SETTINGS = dict(help_option_names=['-h', '--help'])
# Define our default configuration we use if nothing is otherwise specified
DEFAULT_SEARCH_PATHS = (
DEFAULT_CONFIG_PATHS = (
# Legacy Path Support
'~/.apprise',
'~/.apprise.yml',
'~/.config/apprise',
'~/.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
if platform.system() == 'Windows':
# Default Search Path for Windows Users
DEFAULT_SEARCH_PATHS = (
# Default Config Search Path for Windows Users
DEFAULT_CONFIG_PATHS = (
expandvars('%APPDATA%/Apprise/apprise'),
expandvars('%APPDATA%/Apprise/apprise.yml'),
expandvars('%LOCALAPPDATA%/Apprise/apprise'),
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):
"""
@ -107,6 +127,9 @@ def print_version_msg():
@click.option('--title', '-t', default=None, type=str,
help='Specify the message title. This field is complete '
'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,
metavar='CONFIG_URL',
help='Specify one or more configuration locations.')
@ -158,7 +181,7 @@ def print_version_msg():
metavar='SERVER_URL [SERVER_URL2 [SERVER_URL3]]',)
def main(body, title, config, attach, urls, notification_type, theme, tag,
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
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
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
asset = AppriseAsset(
# Our body format
@ -243,11 +272,14 @@ def main(body, title, config, attach, urls, notification_type, theme, tag,
# Set the theme
theme=theme,
# Async mode is only used for Python v3+ and allows a user to send
# all of their notifications asyncronously. This was made an option
# incase there are problems in the future where it's better that
# everything run sequentially/syncronously instead.
# Async mode allows a user to send all of their notifications
# asynchronously. This was made an option incase there are problems
# in the future where it is better that everything runs sequentially/
# synchronously instead.
async_mode=disable_async is not True,
# Load our plugins
plugin_paths=plugin_path,
)
# Create our Apprise object
@ -263,11 +295,11 @@ def main(body, title, config, attach, urls, notification_type, theme, tag,
for entry in plugins:
protocols = [] if not entry['protocols'] else \
[p for p in entry['protocols']
if isinstance(p, six.string_types)]
if isinstance(p, str)]
protocols.extend(
[] if not entry['secure_protocols'] else
[p for p in entry['secure_protocols']
if isinstance(p, six.string_types)])
if isinstance(p, str)])
if len(protocols) == 1:
# 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]),
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(
'{} {:<30} '.format(
'+' if entry['enabled'] else '-',
str(entry['service_name'])),
fg="green" if entry['enabled'] else "red", bold=True),
str(entry['service_name'])), fg=fg, bold=True),
nl=(not entry['enabled'] or len(protocols) == 1))
if not entry['enabled']:
@ -307,8 +346,9 @@ def main(body, title, config, attach, urls, notification_type, theme, tag,
click.echo(' - ' + req)
# new line padding between entries
click.echo()
continue
if entry['category'] == 'native':
click.echo()
continue
if len(protocols) > 1:
click.echo('| Schema(s): {}'.format(
@ -324,6 +364,7 @@ def main(body, title, config, attach, urls, notification_type, theme, tag,
click.echo()
sys.exit(0)
# end if details()
# The priorities of what is accepted are parsed in order below:
# 1. URLs by command line
@ -372,13 +413,14 @@ def main(body, title, config, attach, urls, notification_type, theme, tag,
a.add(AppriseConfig(
paths=os.environ['APPRISE_CONFIG'].strip(),
asset=asset, recursion=recursion_depth))
else:
# Load default configuration
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))
if len(a) == 0:
if len(a) == 0 and not urls:
logger.error(
'You must specify at least one server URL or populated '
'configuration file.')

View file

@ -24,7 +24,52 @@
# 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
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
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
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
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
apprise library.
@ -130,7 +175,7 @@ CONFIG_FORMATS = (
)
class ContentIncludeMode(object):
class ContentIncludeMode:
"""
The different Content inclusion modes. All content based plugins will
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
to track the source of the attachment itself. We don't want

View file

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

View file

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

View file

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

View file

@ -24,14 +24,11 @@
# THE SOFTWARE.
import re
import six
from os import listdir
from os.path import dirname
from os.path import abspath
from ..logger import logger
# Maintains a mapping of all of the configuration services
SCHEMA_MAP = {}
from ..common import CONFIG_SCHEMA_MAP
__all__ = []
@ -89,40 +86,20 @@ def __load_matrix(path=abspath(dirname(__file__)), name='apprise.config'):
globals()[plugin_name] = plugin
fn = getattr(plugin, 'schemas', None)
try:
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)
schemas = set([]) if not callable(fn) else fn(plugin)
# map our schema to our plugin
for schema in schemas:
if schema in SCHEMA_MAP:
if schema in CONFIG_SCHEMA_MAP:
logger.error(
"Config schema ({}) mismatch detected - {} to {}"
.format(schema, SCHEMA_MAP[schema], plugin))
.format(schema, CONFIG_SCHEMA_MAP[schema], plugin))
continue
# Assign plugin
SCHEMA_MAP[schema] = plugin
CONFIG_SCHEMA_MAP[schema] = plugin
return SCHEMA_MAP
return CONFIG_SCHEMA_MAP
# 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
# THE SOFTWARE.
import re
import six
from markdown import markdown
from .common import NotifyFormat
from .URLBase import URLBase
if six.PY2:
from HTMLParser import HTMLParser
else:
from html.parser import HTMLParser
from html.parser import HTMLParser
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.
"""
return URLBase.escape_html(content)
# First eliminate any carriage returns
return URLBase.escape_html(content, convert_new_lines=True)
def html_to_text(content):
@ -79,10 +74,6 @@ def html_to_text(content):
"""
parser = HTMLConverter()
if six.PY2:
# Python 2.7 requires an additional parsing to un-escape characters
content = parser.unescape(content)
parser.feed(content)
parser.close()
return parser.converted
@ -96,7 +87,9 @@ class HTMLConverter(HTMLParser, object):
'div', 'td', 'th', 'code', 'pre', 'label', 'li',)
# 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
WS_TRIM = re.compile(r'[\s]+', re.DOTALL | re.MULTILINE)
@ -122,14 +115,6 @@ class HTMLConverter(HTMLParser, object):
string = ''.join(self._finalize(self._result))
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):
"""
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)
class LogCapture(object):
class LogCapture:
"""
A class used to allow one to instantiate loggers that write to
memory for temporary purposes. e.g.:

View file

@ -24,7 +24,6 @@
# THE SOFTWARE.
import re
import six
import requests
from json import dumps
@ -137,7 +136,7 @@ class NotifyAppriseAPI(NotifyBase):
super(NotifyAppriseAPI, self).__init__(**kwargs)
self.fullpath = kwargs.get('fullpath')
if not isinstance(self.fullpath, six.string_types):
if not isinstance(self.fullpath, str):
self.fullpath = '/'
self.token = validate_regex(
@ -339,18 +338,10 @@ class NotifyAppriseAPI(NotifyBase):
return results
# Add our headers that the user can potentially over-ride if they wish
# to to our returned result set
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
# to to our returned result set and tidy entries by unquoting them
results['headers'] = \
{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
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.
import re
import six
from ..URLBase import URLBase
from ..common import NotifyType
@ -37,14 +36,9 @@ from ..AppriseLocale import gettext_lazy as _
from ..AppriseAttachment import AppriseAttachment
if six.PY3:
# Wrap our base with the asyncio wrapper
from ..py3compat.asyncio import AsyncNotifyBase
BASE_OBJECT = AsyncNotifyBase
else:
# Python v2.7 (backwards compatibility)
BASE_OBJECT = URLBase
# Wrap our base with the asyncio wrapper
from ..py3compat.asyncio import AsyncNotifyBase
BASE_OBJECT = AsyncNotifyBase
class NotifyBase(BASE_OBJECT):
@ -59,6 +53,15 @@ class NotifyBase(BASE_OBJECT):
# enabled.
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
# already by Apprise.
#

View file

@ -24,7 +24,6 @@
# THE SOFTWARE.
import re
import six
import requests
import hmac
from json import dumps
@ -181,7 +180,7 @@ class NotifyBoxcar(NotifyBase):
self.tags.append(DEFAULT_TAG)
targets = []
elif isinstance(targets, six.string_types):
elif isinstance(targets, str):
targets = [x for x in filter(bool, TAGS_LIST_DELIM.split(
targets,
))]
@ -357,13 +356,8 @@ class NotifyBoxcar(NotifyBase):
# by default
entries = NotifyBoxcar.split_path(results['fullpath'])
try:
# Now fetch the remaining tokens
results['secret'] = entries.pop(0)
except IndexError:
# secret wasn't specified
results['secret'] = None
# Now fetch the remaining tokens
results['secret'] = entries.pop(0) if entries else None
# Our recipients make up the remaining entries of our array
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
# account profile area: https://d7networks.com/accounts/profile/
import six
import requests
import base64
from json import dumps
@ -54,7 +53,7 @@ D7NETWORKS_HTTP_ERROR_MAP = {
# Priorities
class D7SMSPriority(object):
class D7SMSPriority:
"""
D7 Networks SMS Message Priority
"""
@ -192,7 +191,7 @@ class NotifyD7Networks(NotifyBase):
# Setup our source address (if defined)
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):
msg = 'A D7 Networks user/pass was not provided.'
@ -232,10 +231,10 @@ class NotifyD7Networks(NotifyBase):
auth = '{user}:{password}'.format(
user=self.user, password=self.password)
if six.PY3:
# Python 3's versio of b64encode() expects a byte array and not
# a string. To accomodate this, we encode the content here
auth = auth.encode('utf-8')
# Python 3's versio of b64encode() expects a byte array and not
# a string. To accommodate this, we encode the content here
auth = auth.encode('utf-8')
# Prepare our headers
headers = {

View file

@ -60,7 +60,7 @@ try:
from dbus.mainloop.glib import DBusGMainLoop
LOOP_GLIB = DBusGMainLoop()
except ImportError:
except ImportError: # pragma: no cover
# No problem
pass
@ -109,18 +109,36 @@ MAINLOOP_MAP = {
# Urgencies
class DBusUrgency(object):
class DBusUrgency:
LOW = 0
NORMAL = 1
HIGH = 2
# Define our urgency levels
DBUS_URGENCIES = (
DBusUrgency.LOW,
DBusUrgency.NORMAL,
DBusUrgency.HIGH,
)
DBUS_URGENCIES = {
# Note: This also acts as a reverse lookup mapping
DBusUrgency.LOW: 'low',
DBusUrgency.NORMAL: 'normal',
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):
@ -143,10 +161,11 @@ class NotifyDBus(NotifyBase):
service_url = 'http://www.freedesktop.org/Software/dbus/'
# 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
# Python v2. So converting the result set back into a list makes us
# compatible
# TODO: Review after dropping support for Python 2.
protocol = list(MAINLOOP_MAP.keys())
# A URL that takes you to the setup/help of the specific protocol
@ -182,6 +201,12 @@ class NotifyDBus(NotifyBase):
'values': DBUS_URGENCIES,
'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': {
'name': _('X-Axis'),
'type': 'int',
@ -223,15 +248,29 @@ class NotifyDBus(NotifyBase):
raise TypeError(msg)
# The urgency of the message
if urgency not in DBUS_URGENCIES:
self.urgency = DBusUrgency.NORMAL
else:
self.urgency = urgency
self.urgency = int(
NotifyDBus.template_args['urgency']['default']
if urgency is None else
next((
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
self.x_axis = x_axis if isinstance(x_axis, int) else None
self.y_axis = y_axis if isinstance(y_axis, int) else None
if x_axis or y_axis:
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
# or not.
@ -343,17 +382,13 @@ class NotifyDBus(NotifyBase):
Returns the URL built dynamically based on specified arguments.
"""
_map = {
DBusUrgency.LOW: 'low',
DBusUrgency.NORMAL: 'normal',
DBusUrgency.HIGH: 'high',
}
# Define any URL parameters
params = {
'image': 'yes' if self.include_image else 'no',
'urgency': 'normal' if self.urgency not in _map
else _map[self.urgency],
'urgency':
DBUS_URGENCIES[self.template_args['urgency']['default']]
if self.urgency not in DBUS_URGENCIES
else DBUS_URGENCIES[self.urgency],
}
# Extend our parameters
@ -389,38 +424,20 @@ class NotifyDBus(NotifyBase):
# DBus supports urgency, but we we also support the keyword priority
# so that it is consistent with some of the other plugins
urgency = results['qsd'].get('urgency', results['qsd'].get('priority'))
if urgency and len(urgency):
_map = {
'0': DBusUrgency.LOW,
'l': DBusUrgency.LOW,
'n': DBusUrgency.NORMAL,
'1': DBusUrgency.NORMAL,
'h': DBusUrgency.HIGH,
'2': DBusUrgency.HIGH,
}
if 'priority' in results['qsd'] and len(results['qsd']['priority']):
# We intentionally store the priority in the urgency section
results['urgency'] = \
NotifyDBus.unquote(results['qsd']['priority'])
try:
# Attempt to index/retrieve our urgency
results['urgency'] = _map[urgency[0].lower()]
except KeyError:
# No priority was set
pass
if 'urgency' in results['qsd'] and len(results['qsd']['urgency']):
results['urgency'] = \
NotifyDBus.unquote(results['qsd']['urgency'])
# handle x,y coordinates
try:
results['x_axis'] = int(results['qsd'].get('x'))
if 'x' in results['qsd'] and len(results['qsd']['x']):
results['x_axis'] = NotifyDBus.unquote(results['qsd'].get('x'))
except (TypeError, ValueError):
# No x was set
pass
try:
results['y_axis'] = int(results['qsd'].get('y'))
except (TypeError, ValueError):
# No y was set
pass
if 'y' in results['qsd'] and len(results['qsd']['y']):
results['y_axis'] = NotifyDBus.unquote(results['qsd'].get('y'))
return results

View file

@ -58,15 +58,27 @@ from ..utils import parse_list
from ..utils import parse_bool
class DapnetPriority(object):
class DapnetPriority:
NORMAL = 0
EMERGENCY = 1
DAPNET_PRIORITIES = (
DapnetPriority.NORMAL,
DapnetPriority.EMERGENCY,
)
DAPNET_PRIORITIES = {
DapnetPriority.NORMAL: 'normal',
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):
@ -172,11 +184,14 @@ class NotifyDapnet(NotifyBase):
# Parse our targets
self.targets = list()
# get the emergency prio setting
if priority not in DAPNET_PRIORITIES:
self.priority = self.template_args['priority']['default']
else:
self.priority = priority
# The Priority of the message
self.priority = int(
NotifyDapnet.template_args['priority']['default']
if priority is None else
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):
msg = 'A Dapnet user/pass was not provided.'
@ -201,8 +216,7 @@ class NotifyDapnet(NotifyBase):
)
continue
# Store callsign without SSID and
# ignore duplicates
# Store callsign without SSID and ignore duplicates
if result['callsign'] not in self.targets:
self.targets.append(result['callsign'])
@ -230,10 +244,6 @@ class NotifyDapnet(NotifyBase):
# error tracking (used for function return)
has_error = False
# prepare the emergency mode
emergency_mode = True \
if self.priority == DapnetPriority.EMERGENCY else False
# Create a copy of the targets list
targets = list(self.targets)
@ -244,7 +254,7 @@ class NotifyDapnet(NotifyBase):
'text': body,
'callSignNames': targets[index:index + batch_size],
'transmitterGroupNames': self.txgroups,
'emergency': emergency_mode,
'emergency': (self.priority == DapnetPriority.EMERGENCY),
}
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.
"""
# Define any URL parameters
_map = {
DapnetPriority.NORMAL: 'normal',
DapnetPriority.EMERGENCY: 'emergency',
}
# Define any URL parameters
params = {
'priority': 'normal' if self.priority not in _map
else _map[self.priority],
'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',
'txgroups': ','.join(self.txgroups),
}
@ -361,25 +367,10 @@ class NotifyDapnet(NotifyBase):
results['targets'] += \
NotifyDapnet.parse_list(results['qsd']['to'])
# Check for priority
# Set our priority
if 'priority' in results['qsd'] and len(results['qsd']['priority']):
_map = {
# Letter Assignments
'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
results['priority'] = \
NotifyDapnet.unquote(results['qsd']['priority'])
# Check for one or multiple transmitter groups (comma separated)
# and split them up, when necessary

View file

@ -128,6 +128,12 @@ class NotifyDiscord(NotifyBase):
'name': _('Avatar URL'),
'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': {
'name': _('Display Footer'),
'type': 'bool',
@ -153,7 +159,7 @@ class NotifyDiscord(NotifyBase):
def __init__(self, webhook_id, webhook_token, tts=False, avatar=True,
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
@ -194,6 +200,9 @@ class NotifyDiscord(NotifyBase):
# Use Fields
self.fields = fields
# Specified Thread ID
self.thread_id = thread
# Avatar URL
# This allows a user to provide an over-ride to the otherwise
# dynamically generated avatar url images
@ -274,6 +283,9 @@ class NotifyDiscord(NotifyBase):
payload['content'] = \
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):
payload['avatar_url'] = \
self.avatar_url if self.avatar_url else image_url
@ -447,6 +459,9 @@ class NotifyDiscord(NotifyBase):
if self.avatar_url:
params['avatar_url'] = self.avatar_url
if self.thread_id:
params['thread'] = self.thread_id
# Extend our parameters
params.update(self.url_parameters(privacy=privacy, *args, **kwargs))
@ -515,6 +530,11 @@ class NotifyDiscord(NotifyBase):
results['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
@staticmethod

View file

@ -24,12 +24,11 @@
# THE SOFTWARE.
import re
import six
import smtplib
from email.mime.text import MIMEText
from email.mime.application import MIMEApplication
from email.mime.multipart import MIMEMultipart
from email.utils import formataddr
from email.utils import formataddr, make_msgid
from email.header import Header
from email import charset
@ -38,17 +37,16 @@ from datetime import datetime
from .NotifyBase import NotifyBase
from ..URLBase import PrivacyMode
from ..common import NotifyFormat
from ..common import NotifyType
from ..utils import is_email
from ..utils import parse_emails
from ..common import NotifyFormat, NotifyType
from ..conversion import convert_between
from ..utils import is_email, parse_emails
from ..AppriseLocale import gettext_lazy as _
# Globally Default encoding mode set to Quoted Printable.
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
to best formulate a login to it using the data detected
@ -61,7 +59,7 @@ class WebBaseLogin(object):
# Secure Email Modes
class SecureMailMode(object):
class SecureMailMode:
SSL = "ssl"
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',
@ -126,7 +109,7 @@ EMAIL_TEMPLATES = (
'Microsoft Hotmail',
re.compile(
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,
'smtp_host': 'smtp-mail.outlook.com',
@ -412,6 +395,11 @@ class NotifyEmail(NotifyBase):
'default': SecureMailMode.STARTTLS,
'map_to': 'secure_mode',
},
'reply': {
'name': _('Reply To'),
'type': 'list:string',
'map_to': 'reply_to',
},
})
# Define any kwargs we're using
@ -424,7 +412,7 @@ class NotifyEmail(NotifyBase):
def __init__(self, smtp_host=None, from_name=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
@ -450,6 +438,9 @@ class NotifyEmail(NotifyBase):
# Acquire Blind Carbon Copies
self.bcc = set()
# Acquire Reply To
self.reply_to = set()
# For tracking our email -> name lookups
self.names = {}
@ -482,13 +473,17 @@ class NotifyEmail(NotifyBase):
# Set our from 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
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
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()
if self.secure_mode not in SECURE_MODES:
msg = 'The secure mode specified ({}) is invalid.'\
@ -548,8 +543,24 @@ class NotifyEmail(NotifyBase):
'({}) 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
self.NotifyEmailDefaults()
self.NotifyEmailDefaults(secure_mode=secure_mode, **kwargs)
# if there is still no smtp_host then we fall back to the hostname
if not self.smtp_host:
@ -557,7 +568,7 @@ class NotifyEmail(NotifyBase):
return
def NotifyEmailDefaults(self):
def NotifyEmailDefaults(self, secure_mode=None, port=None, **kwargs):
"""
A function that prefills defaults based on the email
it was provided.
@ -586,18 +597,23 @@ class NotifyEmail(NotifyBase):
'Applying %s Defaults' %
EMAIL_TEMPLATES[i][0],
)
self.port = EMAIL_TEMPLATES[i][2]\
.get('port', self.port)
# the secure flag can not be altered if defined in the template
self.secure = EMAIL_TEMPLATES[i][2]\
.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]\
.get('smtp_host', self.smtp_host)
if self.smtp_host is None:
# default to our host
self.smtp_host = self.host
# The following can be over-ridden if defined manually in the
# Apprise URL. Otherwise they take on the template value
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
# was specified, then we default to having them all set (which
@ -622,6 +638,18 @@ class NotifyEmail(NotifyBase):
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,
**kwargs):
"""
@ -652,26 +680,24 @@ class NotifyEmail(NotifyBase):
# Strip target out of bcc list if in To
bcc = (self.bcc - set([to_addr]))
try:
# Format our cc addresses to support the Name field
cc = [formataddr(
# Strip target out of reply_to list if in To
reply_to = (self.reply_to - set([to_addr]))
# 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')
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]
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]
for addr in reply_to]
self.logger.debug(
'Email From: {} <{}>'.format(from_name, self.from_addr))
@ -680,45 +706,29 @@ class NotifyEmail(NotifyBase):
self.logger.debug('Email Cc: {}'.format(', '.join(cc)))
if 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(
'Delivery: {}:{}'.format(self.smtp_host, self.port))
# Prepare Email Message
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:
content = 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
base = MIMEText(body, 'plain', 'utf-8')
if attach:
# First attach our body to our content as the first element
base.attach(content)
mixed = MIMEMultipart("mixed")
mixed.attach(base)
# Now store our attachments
for attachment in attach:
if not attachment:
@ -745,8 +755,28 @@ class NotifyEmail(NotifyBase):
'attachment; filename="{}"'.format(
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
socket = None
@ -839,6 +869,14 @@ class NotifyEmail(NotifyBase):
'' if not e not in self.names
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)
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
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
if 'smtp' in results['qsd'] and len(results['qsd']['smtp']):
# Extract the smtp server
@ -940,6 +971,10 @@ class NotifyEmail(NotifyBase):
if 'bcc' in results['qsd'] and len(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['smtp_host'] = smtp_host

View file

@ -677,7 +677,7 @@ class NotifyEmby(NotifyBase):
def __del__(self):
"""
Deconstructor
Destructor
"""
try:
self.logout()
@ -694,20 +694,20 @@ class NotifyEmby(NotifyBase):
# - https://bugs.python.org/issue29288
#
# 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
pass
except ImportError: # pragma: no cover
# 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
# 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
# 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.
# https://stackoverflow.com/questions/67218341/\
@ -719,6 +719,6 @@ class NotifyEmby(NotifyBase):
# /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)
# 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.
pass

View file

@ -31,7 +31,6 @@
# - https://github.com/E2OpenPlugins/e2openplugin-OpenWebif/wiki/\
# OpenWebif-API-documentation#message
import six
import requests
from json import loads
@ -41,7 +40,7 @@ from ..common import NotifyType
from ..AppriseLocale import gettext_lazy as _
class Enigma2MessageType(object):
class Enigma2MessageType:
# Defines the Enigma2 notification types Apprise can map to
INFO = 1
WARNING = 2
@ -169,7 +168,7 @@ class NotifyEnigma2(NotifyBase):
self.timeout = self.template_args['timeout']['default']
self.fullpath = kwargs.get('fullpath')
if not isinstance(self.fullpath, six.string_types):
if not isinstance(self.fullpath, str):
self.fullpath = '/'
self.headers = {}
@ -337,18 +336,10 @@ class NotifyEnigma2(NotifyBase):
return results
# Add our headers that the user can potentially over-ride if they wish
# to to our returned result set
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
# to to our returned result set and tidy entries by unquoting them
results['headers'] = {
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)
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
# You will need this in order to send an apprise messag
import six
import requests
from json import dumps
from ..NotifyBase import NotifyBase
@ -53,6 +52,7 @@ from ...common import NotifyType
from ...utils import validate_regex
from ...utils import parse_list
from ...utils import parse_bool
from ...utils import dict_full_update
from ...common import NotifyImageSize
from ...AppriseAttachment import AppriseAttachment
from ...AppriseLocale import gettext_lazy as _
@ -73,7 +73,7 @@ except ImportError:
# cryptography is the dependency of the .oauth library
# Create a dummy object for init() call to work
class GoogleOAuth(object):
class GoogleOAuth:
pass
@ -227,7 +227,7 @@ class NotifyFCM(NotifyBase):
else:
# Setup our mode
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:
msg = 'The FCM mode specified ({}) is invalid.'.format(mode)
self.logger.warning(msg)
@ -450,17 +450,9 @@ class NotifyFCM(NotifyBase):
"FCM recipient %s parsed as a device token",
recipient)
#
# Apply our priority configuration (if set)
#
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())
# A more advanced dict.update() that recursively includes
# sub-dictionaries as well
dict_full_update(payload, self.priority.payload())
self.logger.debug(
'FCM %s POST URL: %s (cert_verify=%r)',

View file

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

View file

@ -22,7 +22,7 @@
# 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.
class FCMMode(object):
class FCMMode:
"""
Define the Firebase Cloud Messaging Modes
"""

View file

@ -29,7 +29,6 @@
# 2. Click Generate New Private Key, then confirm by clicking Generate Key.
# 3. Securely store the JSON file containing the key.
import io
import requests
import base64
import json
@ -41,26 +40,13 @@ from cryptography.hazmat.primitives import asymmetric
from cryptography.exceptions import UnsupportedAlgorithm
from datetime import datetime
from datetime import timedelta
from json.decoder import JSONDecodeError
from urllib.parse import urlencode as _urlencode
from ...logger import logger
try:
# Python 2.7
from urllib import urlencode as _urlencode
except ImportError:
# 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):
class GoogleOAuth:
"""
A OAuth simplified implimentation to Google's Firebase Cloud Messaging
@ -127,7 +113,7 @@ class GoogleOAuth(object):
self.__access_token_expiry = datetime.utcnow()
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())
except (OSError, IOError):

View file

@ -33,7 +33,7 @@ from .common import (FCMMode, FCM_MODES)
from ...logger import logger
class NotificationPriority(object):
class NotificationPriority:
"""
Defines the Notification Priorities as described on:
https://firebase.google.com/docs/reference/fcm/rest/v1/\
@ -63,7 +63,7 @@ class NotificationPriority(object):
HIGH = 'HIGH'
class FCMPriority(object):
class FCMPriority:
"""
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
"""
@ -242,14 +242,7 @@ class FCMPriorityManager(object):
def __bool__(self):
"""
Allows this object to be wrapped in an Python 3.x based 'if
statement'. 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
Allows this object to be wrapped in an 'if statement'.
True is returned if a priority was loaded
"""
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
# THE SOFTWARE.
import six
import requests
from .NotifyBase import NotifyBase
@ -137,11 +136,11 @@ class NotifyForm(NotifyBase):
super(NotifyForm, self).__init__(**kwargs)
self.fullpath = kwargs.get('fullpath')
if not isinstance(self.fullpath, six.string_types):
if not isinstance(self.fullpath, str):
self.fullpath = ''
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:
msg = 'The method specified ({}) is invalid.'.format(method)
@ -374,17 +373,9 @@ class NotifyForm(NotifyBase):
for x, y in results['qsd:'].items()}
# Add our headers that the user can potentially over-ride if they wish
# to to our returned result set
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
# to to our returned result set and tidy entries by unquoting them
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
if 'method' in results['qsd'] and len(results['qsd']['method']):

View file

@ -60,17 +60,36 @@ except (ImportError, ValueError, AttributeError):
# Urgencies
class GnomeUrgency(object):
class GnomeUrgency:
LOW = 0
NORMAL = 1
HIGH = 2
GNOME_URGENCIES = (
GnomeUrgency.LOW,
GnomeUrgency.NORMAL,
GnomeUrgency.HIGH,
)
GNOME_URGENCIES = {
GnomeUrgency.LOW: 'low',
GnomeUrgency.NORMAL: 'normal',
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):
@ -126,6 +145,12 @@ class NotifyGnome(NotifyBase):
'values': GNOME_URGENCIES,
'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': {
'name': _('Include Image'),
'type': 'bool',
@ -142,11 +167,13 @@ class NotifyGnome(NotifyBase):
super(NotifyGnome, self).__init__(**kwargs)
# The urgency of the message
if urgency not in GNOME_URGENCIES:
self.urgency = self.template_args['urgency']['default']
else:
self.urgency = urgency
self.urgency = int(
NotifyGnome.template_args['urgency']['default']
if urgency is None else
next((
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
# or not.
@ -205,17 +232,13 @@ class NotifyGnome(NotifyBase):
Returns the URL built dynamically based on specified arguments.
"""
_map = {
GnomeUrgency.LOW: 'low',
GnomeUrgency.NORMAL: 'normal',
GnomeUrgency.HIGH: 'high',
}
# Define any URL parameters
params = {
'image': 'yes' if self.include_image else 'no',
'urgency': 'normal' if self.urgency not in _map
else _map[self.urgency],
'urgency':
GNOME_URGENCIES[self.template_args['urgency']['default']]
if self.urgency not in GNOME_URGENCIES
else GNOME_URGENCIES[self.urgency],
}
# Extend our parameters
@ -243,23 +266,13 @@ class NotifyGnome(NotifyBase):
# Gnome supports urgency, but we we also support the keyword priority
# so that it is consistent with some of the other plugins
urgency = results['qsd'].get('urgency', results['qsd'].get('priority'))
if urgency and len(urgency):
_map = {
'0': GnomeUrgency.LOW,
'l': GnomeUrgency.LOW,
'n': GnomeUrgency.NORMAL,
'1': GnomeUrgency.NORMAL,
'h': GnomeUrgency.HIGH,
'2': GnomeUrgency.HIGH,
}
if 'priority' in results['qsd'] and len(results['qsd']['priority']):
# We intentionally store the priority in the urgency section
results['urgency'] = \
NotifyGnome.unquote(results['qsd']['priority'])
try:
# Attempt to index/retrieve our urgency
results['urgency'] = _map[urgency[0].lower()]
except KeyError:
# No priority was set
pass
if 'urgency' in results['qsd'] and len(results['qsd']['urgency']):
results['urgency'] = \
NotifyGnome.unquote(results['qsd']['urgency'])
return results

View file

@ -41,7 +41,7 @@ from ..AppriseLocale import gettext_lazy as _
# Priorities
class GotifyPriority(object):
class GotifyPriority:
LOW = 0
MODERATE = 3
NORMAL = 5
@ -49,13 +49,37 @@ class GotifyPriority(object):
EMERGENCY = 10
GOTIFY_PRIORITIES = (
GotifyPriority.LOW,
GotifyPriority.MODERATE,
GotifyPriority.NORMAL,
GotifyPriority.HIGH,
GotifyPriority.EMERGENCY,
)
GOTIFY_PRIORITIES = {
# Note: This also acts as a reverse lookup mapping
GotifyPriority.LOW: 'low',
GotifyPriority.MODERATE: 'moderate',
GotifyPriority.NORMAL: 'normal',
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):
@ -144,11 +168,14 @@ class NotifyGotify(NotifyBase):
# prepare our fullpath
self.fullpath = kwargs.get('fullpath', '/')
if priority not in GOTIFY_PRIORITIES:
self.priority = GotifyPriority.NORMAL
else:
self.priority = priority
# The Priority of the message
self.priority = int(
NotifyGotify.template_args['priority']['default']
if priority is None else
next((
v for k, v in GOTIFY_PRIORITY_MAP.items()
if str(priority).lower().startswith(k)),
NotifyGotify.template_args['priority']['default']))
if self.secure:
self.schema = 'https'
@ -246,7 +273,10 @@ class NotifyGotify(NotifyBase):
# Define any URL parameters
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
@ -294,20 +324,9 @@ class NotifyGotify(NotifyBase):
results['fullpath'] = \
'/' if not entries else '/{}/'.format('/'.join(entries))
# Set our priority
if 'priority' in results['qsd'] and len(results['qsd']['priority']):
_map = {
'l': GotifyPriority.LOW,
'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
results['priority'] = \
NotifyGotify.unquote(results['qsd']['priority'])
return results

View file

@ -46,7 +46,7 @@ except ImportError:
# Priorities
class GrowlPriority(object):
class GrowlPriority:
LOW = -2
MODERATE = -1
NORMAL = 0
@ -54,13 +54,34 @@ class GrowlPriority(object):
EMERGENCY = 2
GROWL_PRIORITIES = (
GrowlPriority.LOW,
GrowlPriority.MODERATE,
GrowlPriority.NORMAL,
GrowlPriority.HIGH,
GrowlPriority.EMERGENCY,
)
GROWL_PRIORITIES = {
# Note: This also acts as a reverse lookup mapping
GrowlPriority.LOW: 'low',
GrowlPriority.MODERATE: 'moderate',
GrowlPriority.NORMAL: 'normal',
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):
@ -172,11 +193,12 @@ class NotifyGrowl(NotifyBase):
self.port = self.default_port
# The Priority of the message
if priority not in GROWL_PRIORITIES:
self.priority = GrowlPriority.NORMAL
else:
self.priority = priority
self.priority = NotifyGrowl.template_args['priority']['default'] \
if not priority else \
next((
v for k, v in GROWL_PRIORITY_MAP.items()
if str(priority).lower().startswith(k)),
NotifyGrowl.template_args['priority']['default'])
# Our Registered object
self.growl = None
@ -318,21 +340,14 @@ class NotifyGrowl(NotifyBase):
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
params = {
'image': 'yes' if self.include_image else 'no',
'sticky': 'yes' if self.sticky else 'no',
'priority':
_map[GrowlPriority.NORMAL] if self.priority not in _map
else _map[self.priority],
GROWL_PRIORITIES[self.template_args['priority']['default']]
if self.priority not in GROWL_PRIORITIES
else GROWL_PRIORITIES[self.priority],
'version': self.version,
}
@ -384,33 +399,10 @@ class NotifyGrowl(NotifyBase):
)
pass
# Set our priority
if 'priority' in results['qsd'] and len(results['qsd']['priority']):
_map = {
# Letter Assignments
'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
results['priority'] = \
NotifyGrowl.unquote(results['qsd']['priority'])
# Because of the URL formatting, the password is actually where the
# 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
'name': _('Notification ID'),
'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
# THE SOFTWARE.
import six
import requests
import base64
from json import dumps
@ -139,11 +138,11 @@ class NotifyJSON(NotifyBase):
super(NotifyJSON, self).__init__(**kwargs)
self.fullpath = kwargs.get('fullpath')
if not isinstance(self.fullpath, six.string_types):
if not isinstance(self.fullpath, str):
self.fullpath = ''
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:
msg = 'The method specified ({}) is invalid.'.format(method)
@ -361,17 +360,9 @@ class NotifyJSON(NotifyBase):
for x, y in results['qsd:'].items()}
# Add our headers that the user can potentially over-ride if they wish
# to to our returned result set
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
# to to our returned result set and tidy entries by unquoting them
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
if 'method' in results['qsd'] and len(results['qsd']['method']):

View file

@ -63,7 +63,7 @@ JOIN_IMAGE_XY = NotifyImageSize.XY_72
# Priorities
class JoinPriority(object):
class JoinPriority:
LOW = -2
MODERATE = -1
NORMAL = 0
@ -71,13 +71,34 @@ class JoinPriority(object):
EMERGENCY = 2
JOIN_PRIORITIES = (
JoinPriority.LOW,
JoinPriority.MODERATE,
JoinPriority.NORMAL,
JoinPriority.HIGH,
JoinPriority.EMERGENCY,
)
JOIN_PRIORITIES = {
# Note: This also acts as a reverse lookup mapping
JoinPriority.LOW: 'low',
JoinPriority.MODERATE: 'moderate',
JoinPriority.NORMAL: 'normal',
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):
@ -189,11 +210,13 @@ class NotifyJoin(NotifyBase):
raise TypeError(msg)
# The Priority of the message
if priority not in JOIN_PRIORITIES:
self.priority = self.template_args['priority']['default']
else:
self.priority = priority
self.priority = int(
NotifyJoin.template_args['priority']['default']
if priority is None else
next((
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
self.targets = list()
@ -324,19 +347,12 @@ class NotifyJoin(NotifyBase):
"""
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
params = {
'priority':
_map[self.template_args['priority']['default']]
if self.priority not in _map else _map[self.priority],
JOIN_PRIORITIES[self.template_args['priority']['default']]
if self.priority not in JOIN_PRIORITIES
else JOIN_PRIORITIES[self.priority],
'image': 'yes' if self.include_image else 'no',
}
@ -371,20 +387,8 @@ class NotifyJoin(NotifyBase):
# Set our priority
if 'priority' in results['qsd'] and len(results['qsd']['priority']):
_map = {
'l': JoinPriority.LOW,
'm': JoinPriority.MODERATE,
'n': JoinPriority.NORMAL,
'h': JoinPriority.HIGH,
'e': JoinPriority.EMERGENCY,
}
try:
results['priority'] = \
_map[results['qsd']['priority'][0].lower()]
except KeyError:
# No priority was set
pass
results['priority'] = \
NotifyJoin.unquote(results['qsd']['priority'])
# Our Devices
results['targets'] = list()

View file

@ -85,7 +85,6 @@
import re
import six
import requests
from json import dumps
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)
class LametricMode(object):
class LametricMode:
"""
Define Lametric Notification Modes
"""
@ -121,7 +120,7 @@ LAMETRIC_MODES = (
)
class LametricPriority(object):
class LametricPriority:
"""
Priority of the message
"""
@ -158,7 +157,7 @@ LAMETRIC_PRIORITIES = (
)
class LametricIconType(object):
class LametricIconType:
"""
Represents the nature of notification.
"""
@ -184,7 +183,7 @@ LAMETRIC_ICON_TYPES = (
)
class LametricSoundCategory(object):
class LametricSoundCategory:
"""
Define Sound Categories
"""
@ -192,7 +191,7 @@ class LametricSoundCategory(object):
ALARMS = "alarms"
class LametricSound(object):
class LametricSound:
"""
There are 2 categories of sounds, to make things simple we just lump them
all togther in one class object.
@ -471,7 +470,7 @@ class NotifyLametric(NotifyBase):
super(NotifyLametric, self).__init__(**kwargs)
self.mode = mode.strip().lower() \
if isinstance(mode, six.string_types) \
if isinstance(mode, str) \
else self.template_args['mode']['default']
# Default Cloud Argument
@ -543,7 +542,7 @@ class NotifyLametric(NotifyBase):
# assign our icon (if it was defined); we also eliminate
# any hashtag (#) entries that might be present
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:
self.icon_type = self.template_args['icon_type']['default']
@ -557,7 +556,7 @@ class NotifyLametric(NotifyBase):
cycles > self.template_args['cycles']['min']) else cycles
self.sound = None
if isinstance(sound, six.string_types):
if isinstance(sound, str):
# If sound is set, get it's match
self.sound = self.sound_lookup(sound.strip().lower())
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
import ssl
import re
import six
from time import sleep
from datetime import datetime
from os.path import isfile
@ -46,11 +45,6 @@ from ..AppriseLocale import gettext_lazy as _
# Default our global support flag
NOTIFY_MQTT_SUPPORT_ENABLED = False
if six.PY2:
# handle Python v2.7 suport
class ConnectionError(Exception):
pass
try:
# 3rd party modules
import paho.mqtt.client as mqtt

View file

@ -41,7 +41,7 @@ from ..utils import validate_regex
from ..AppriseLocale import gettext_lazy as _
class MSG91Route(object):
class MSG91Route:
"""
Transactional SMS Routes
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
"""

View file

@ -76,6 +76,7 @@
import re
import requests
import json
from json.decoder import JSONDecodeError
from .NotifyBase import NotifyBase
from ..common import NotifyImageSize
@ -88,13 +89,6 @@ from ..utils import TemplateType
from ..AppriseAttachment import AppriseAttachment
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):
"""

View file

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

View file

@ -28,7 +28,6 @@
# - https://github.com/matrix-org/synapse/blob/master/docs/reverse_proxy.rst
#
import re
import six
import requests
from markdown import markdown
from json import dumps
@ -67,7 +66,7 @@ IS_ROOM_ID = re.compile(
r'(?P<home_server>[a-z0-9.-]+))?\s*$', re.I)
class MatrixMessageType(object):
class MatrixMessageType:
"""
The Matrix Message types
"""
@ -82,7 +81,7 @@ MATRIX_MESSAGE_TYPES = (
)
class MatrixWebhookMode(object):
class MatrixWebhookMode:
# Webhook Mode is disabled
DISABLED = "off"
@ -263,7 +262,7 @@ class NotifyMatrix(NotifyBase):
# Setup our mode
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:
msg = 'The mode specified ({}) is invalid.'.format(mode)
self.logger.warning(msg)
@ -271,7 +270,7 @@ class NotifyMatrix(NotifyBase):
# Setup our message type
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:
msg = 'The msgtype specified ({}) is invalid.'.format(msgtype)
self.logger.warning(msg)
@ -411,7 +410,7 @@ class NotifyMatrix(NotifyBase):
"""
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 = {
# 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
return None
if not isinstance(room, six.string_types):
if not isinstance(room, str):
# Not a supported string
return None
@ -850,7 +849,7 @@ class NotifyMatrix(NotifyBase):
# We can't create a room if we're not logged in
return None
if not isinstance(room, six.string_types):
if not isinstance(room, str):
# Not a supported string
return None
@ -930,7 +929,7 @@ class NotifyMatrix(NotifyBase):
# We can't get a room id if we're not logged in
return None
if not isinstance(room, six.string_types):
if not isinstance(room, str):
# Not a supported string
return None
@ -1109,20 +1108,20 @@ class NotifyMatrix(NotifyBase):
# - https://bugs.python.org/issue29288
#
# 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
pass
except ImportError: # pragma: no cover
# 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
# 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
# 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.
# https://stackoverflow.com/questions/67218341/\
@ -1134,7 +1133,7 @@ class NotifyMatrix(NotifyBase):
# /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)
# 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.
pass

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,24 +1,31 @@
# MIT License
# Copyright (c) 2022 Joey Espinosa <@particledecay>
# -*- 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
# 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
# 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:
# 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
# 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.
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
# 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:
# ntfys://my-topic
@ -27,7 +34,6 @@
# ntfy://ntfy.local.domain/?priority=max
import re
import requests
import six
from json import loads
from json import dumps
from os.path import basename
@ -43,7 +49,7 @@ from ..URLBase import PrivacyMode
from ..attachment.AttachBase import AttachBase
class NtfyMode(object):
class NtfyMode:
"""
Define ntfy Notification Modes
"""
@ -60,7 +66,7 @@ NTFY_MODES = (
)
class NtfyPriority(object):
class NtfyPriority:
"""
Ntfy Priority Definitions
"""
@ -79,6 +85,39 @@ NTFY_PRIORITIES = (
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):
"""
@ -207,7 +246,7 @@ class NotifyNtfy(NotifyBase):
# Prepare our mode
self.mode = mode.strip().lower() \
if isinstance(mode, six.string_types) \
if isinstance(mode, str) \
else self.template_args['mode']['default']
if self.mode not in NTFY_MODES:
@ -230,18 +269,13 @@ class NotifyNtfy(NotifyBase):
# An email to forward notifications to
self.email = email
# The priority of the message
if priority is None:
self.priority = self.template_args['priority']['default']
else:
self.priority = priority
if self.priority not in NTFY_PRIORITIES:
msg = 'An invalid ntfy Priority ({}) was specified.'.format(
priority)
self.logger.warning(msg)
raise TypeError(msg)
# The Priority of the message
self.priority = NotifyNtfy.template_args['priority']['default'] \
if not priority else \
next((
v for k, v in NTFY_PRIORITY_MAP.items()
if str(priority).lower().startswith(k)),
NotifyNtfy.template_args['priority']['default'])
# Any optional tags to attach to the notification
self.__tags = parse_list(tags)
@ -274,7 +308,7 @@ class NotifyNtfy(NotifyBase):
self.logger.warning('There are no ntfy topics to notify')
return False
# Create a copy of the subreddits list
# Create a copy of the topics
topics = list(self.topics)
while len(topics) > 0:
# Retrieve our topic
@ -558,31 +592,10 @@ class NotifyNtfy(NotifyBase):
# We're done early as we couldn't load the results
return results
# Set our priority
if 'priority' in results['qsd'] and len(results['qsd']['priority']):
_map = {
# Supported lookups
'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
results['priority'] = \
NotifyNtfy.unquote(results['qsd']['priority'])
if 'attach' in results['qsd'] and len(results['qsd']['attach']):
results['attach'] = NotifyNtfy.unquote(results['qsd']['attach'])

View file

@ -74,7 +74,7 @@ OPSGENIE_CATEGORIES = (
# Regions
class OpsgenieRegion(object):
class OpsgenieRegion:
US = 'us'
EU = 'eu'
@ -93,7 +93,7 @@ OPSGENIE_REGIONS = (
# Priorities
class OpsgeniePriority(object):
class OpsgeniePriority:
LOW = 1
MODERATE = 2
NORMAL = 3
@ -101,13 +101,40 @@ class OpsgeniePriority(object):
EMERGENCY = 5
OPSGENIE_PRIORITIES = (
OpsgeniePriority.LOW,
OpsgeniePriority.MODERATE,
OpsgeniePriority.NORMAL,
OpsgeniePriority.HIGH,
OpsgeniePriority.EMERGENCY,
)
OPSGENIE_PRIORITIES = {
# Note: This also acts as a reverse lookup mapping
OpsgeniePriority.LOW: 'low',
OpsgeniePriority.MODERATE: 'moderate',
OpsgeniePriority.NORMAL: 'normal',
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):
@ -246,11 +273,12 @@ class NotifyOpsgenie(NotifyBase):
raise TypeError(msg)
# The Priority of the message
if priority not in OPSGENIE_PRIORITIES:
self.priority = OpsgeniePriority.NORMAL
else:
self.priority = priority
self.priority = NotifyOpsgenie.template_args['priority']['default'] \
if not priority else \
next((
v for k, v in OPSGENIE_PRIORITY_MAP.items()
if str(priority).lower().startswith(k)),
NotifyOpsgenie.template_args['priority']['default'])
# Store our region
try:
@ -353,8 +381,8 @@ class NotifyOpsgenie(NotifyBase):
# Initialize our has_error flag
has_error = False
# We want to manually set the title onto the body if specified
title_body = body if not title else '{}: {}'.format(title, body)
# Use body if title not set
title_body = body if not title else body
# Create a copy ouf our details object
details = self.details.copy()
@ -374,7 +402,7 @@ class NotifyOpsgenie(NotifyBase):
# limitation
if len(payload['message']) > self.opsgenie_body_minlen:
payload['message'] = '{}...'.format(
body[:self.opsgenie_body_minlen - 3])
title_body[:self.opsgenie_body_minlen - 3])
if self.__tags:
payload['tags'] = self.__tags
@ -450,20 +478,13 @@ class NotifyOpsgenie(NotifyBase):
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
params = {
'region': self.region_name,
'priority':
_map[OpsgeniePriority.NORMAL] if self.priority not in _map
else _map[self.priority],
OPSGENIE_PRIORITIES[self.template_args['priority']['default']]
if self.priority not in OPSGENIE_PRIORITIES
else OPSGENIE_PRIORITIES[self.priority],
'batch': 'yes' if self.batch_size > 1 else 'no',
}
@ -530,38 +551,10 @@ class NotifyOpsgenie(NotifyBase):
results['details'] = {NotifyBase.unquote(x): NotifyBase.unquote(y)
for x, y in results['qsd+'].items()}
# Set our priority
if 'priority' in results['qsd'] and len(results['qsd']['priority']):
_map = {
# Letter Assignnments
'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
results['priority'] = \
NotifyOpsgenie.unquote(results['qsd']['priority'])
# Get Batch Boolean (if set)
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
import re
import six
import requests
from json import dumps
@ -40,7 +39,7 @@ TARGET_LIST_DELIM = re.compile(r'[ \t\r\n,\\/]+')
# Priorities
class ParsePlatformDevice(object):
class ParsePlatformDevice:
# All Devices
ALL = 'all'
@ -134,7 +133,7 @@ class NotifyParsePlatform(NotifyBase):
super(NotifyParsePlatform, self).__init__(**kwargs)
self.fullpath = kwargs.get('fullpath')
if not isinstance(self.fullpath, six.string_types):
if not isinstance(self.fullpath, str):
self.fullpath = '/'
# Application ID

View file

@ -32,7 +32,7 @@ from ..AppriseLocale import gettext_lazy as _
# Priorities
class ProwlPriority(object):
class ProwlPriority:
LOW = -2
MODERATE = -1
NORMAL = 0
@ -40,13 +40,34 @@ class ProwlPriority(object):
EMERGENCY = 2
PROWL_PRIORITIES = (
ProwlPriority.LOW,
ProwlPriority.MODERATE,
ProwlPriority.NORMAL,
ProwlPriority.HIGH,
ProwlPriority.EMERGENCY,
)
PROWL_PRIORITIES = {
# Note: This also acts as a reverse lookup mapping
ProwlPriority.LOW: 'low',
ProwlPriority.MODERATE: 'moderate',
ProwlPriority.NORMAL: 'normal',
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:
PROWL_HTTP_ERROR_MAP = {
@ -124,11 +145,13 @@ class NotifyProwl(NotifyBase):
"""
super(NotifyProwl, self).__init__(**kwargs)
if priority not in PROWL_PRIORITIES:
self.priority = self.template_args['priority']['default']
else:
self.priority = priority
# The Priority of the message
self.priority = NotifyProwl.template_args['priority']['default'] \
if not priority else \
next((
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)
self.apikey = validate_regex(
@ -229,18 +252,12 @@ class NotifyProwl(NotifyBase):
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
params = {
'priority': 'normal' if self.priority not in _map
else _map[self.priority],
'priority':
PROWL_PRIORITIES[self.template_args['priority']['default']]
if self.priority not in PROWL_PRIORITIES
else PROWL_PRIORITIES[self.priority],
}
# Extend our parameters
@ -276,32 +293,9 @@ class NotifyProwl(NotifyBase):
except IndexError:
pass
# Set our priority
if 'priority' in results['qsd'] and len(results['qsd']['priority']):
_map = {
# Letter Assignments
'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
results['priority'] = \
NotifyProwl.unquote(results['qsd']['priority'])
return results

View file

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

View file

@ -24,12 +24,12 @@
# THE SOFTWARE.
import re
import six
import requests
from .NotifyBase import NotifyBase
from ..common import NotifyType
from ..common import NotifyFormat
from ..conversion import convert_between
from ..utils import parse_list
from ..utils import validate_regex
from ..AppriseLocale import gettext_lazy as _
@ -43,7 +43,7 @@ VALIDATE_DEVICE = re.compile(r'^[a-z0-9_]{1,25}$', re.I)
# Priorities
class PushoverPriority(object):
class PushoverPriority:
LOW = -2
MODERATE = -1
NORMAL = 0
@ -52,7 +52,7 @@ class PushoverPriority(object):
# Sounds
class PushoverSound(object):
class PushoverSound:
PUSHOVER = 'pushover'
BIKE = 'bike'
BUGLE = 'bugle'
@ -102,13 +102,34 @@ PUSHOVER_SOUNDS = (
PushoverSound.NONE,
)
PUSHOVER_PRIORITIES = (
PushoverPriority.LOW,
PushoverPriority.MODERATE,
PushoverPriority.NORMAL,
PushoverPriority.HIGH,
PushoverPriority.EMERGENCY,
)
PUSHOVER_PRIORITIES = {
# Note: This also acts as a reverse lookup mapping
PushoverPriority.LOW: 'low',
PushoverPriority.MODERATE: 'moderate',
PushoverPriority.NORMAL: 'normal',
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
PUSHOVER_HTTP_ERROR_MAP = {
@ -258,18 +279,20 @@ class NotifyPushover(NotifyBase):
# Setup our 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:
msg = 'The sound specified ({}) is invalid.'.format(sound)
self.logger.warning(msg)
raise TypeError(msg)
# The Priority of the message
if priority not in PUSHOVER_PRIORITIES:
self.priority = self.template_args['priority']['default']
else:
self.priority = priority
self.priority = int(
NotifyPushover.template_args['priority']['default']
if priority is None else
next((
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
if self.priority == PushoverPriority.EMERGENCY:
@ -344,6 +367,10 @@ class NotifyPushover(NotifyBase):
if self.notify_format == NotifyFormat.HTML:
# https://pushover.net/api#html
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:
payload.update({'retry': self.retry, 'expire': self.expire})
@ -404,24 +431,25 @@ class NotifyPushover(NotifyBase):
attach.mimetype,
attach.url(privacy=True)))
return True
attach = None
# If we get here, we're dealing with a supported image.
# Verify that the filesize is okay though.
file_size = len(attach)
if not (file_size > 0
and file_size <= self.attach_max_size_bytes):
else:
# If we get here, we're dealing with a supported image.
# Verify that the filesize is okay though.
file_size = len(attach)
if not (file_size > 0
and file_size <= self.attach_max_size_bytes):
# File size is no good
self.logger.warning(
'Pushover attachment size ({}B) exceeds limit: {}'
.format(file_size, attach.url(privacy=True)))
# File size is no good
self.logger.warning(
'Pushover attachment size ({}B) exceeds limit: {}'
.format(file_size, attach.url(privacy=True)))
return False
return False
self.logger.debug(
'Posting Pushover attachment {}'.format(
attach.url(privacy=True)))
self.logger.debug(
'Posting Pushover attachment {}'.format(
attach.url(privacy=True)))
# Default Header
headers = {
@ -510,19 +538,12 @@ class NotifyPushover(NotifyBase):
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
params = {
'priority':
_map[self.template_args['priority']['default']]
if self.priority not in _map else _map[self.priority],
PUSHOVER_PRIORITIES[self.template_args['priority']['default']]
if self.priority not in PUSHOVER_PRIORITIES
else PUSHOVER_PRIORITIES[self.priority],
}
# Only add expire and retry for emergency messages,
@ -563,20 +584,8 @@ class NotifyPushover(NotifyBase):
# Set our priority
if 'priority' in results['qsd'] and len(results['qsd']['priority']):
_map = {
'l': PushoverPriority.LOW,
'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
results['priority'] = \
NotifyPushover.unquote(results['qsd']['priority'])
# Retrieve all of our targets
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/#POST_api_submit
# - https://github.com/reddit-archive/reddit/wiki/API
import six
import requests
from json import loads
from datetime import timedelta
@ -66,7 +65,7 @@ REDDIT_HTTP_ERROR_MAP = {
}
class RedditMessageKind(object):
class RedditMessageKind:
"""
Define the kinds of messages supported
"""
@ -271,7 +270,7 @@ class NotifyReddit(NotifyBase):
self.__access_token_expiry = datetime.utcnow()
self.kind = kind.strip().lower() \
if isinstance(kind, six.string_types) \
if isinstance(kind, str) \
else self.template_args['kind']['default']
if self.kind not in REDDIT_MESSAGE_KINDS:

View file

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

View file

@ -32,7 +32,6 @@
# These are important <---^----------------------------------------^
#
import re
import six
import requests
from json import dumps
@ -44,7 +43,7 @@ from ..utils import validate_regex
from ..AppriseLocale import gettext_lazy as _
class RyverWebhookMode(object):
class RyverWebhookMode:
"""
Ryver supports to webhook modes
"""
@ -152,7 +151,7 @@ class NotifyRyver(NotifyBase):
# Store our webhook mode
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:
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.utils import formataddr
from email.header import Header
try:
# Python v3.x
from urllib.parse import quote
except ImportError:
# Python v2.x
from urllib import quote
from urllib.parse import quote
from .NotifyBase import NotifyBase
from ..URLBase import PrivacyMode
@ -395,26 +389,15 @@ class NotifySES(NotifyBase):
# Strip target out of bcc list if in To
bcc = (self.bcc - set([to_addr]))
try:
# 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 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]
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]
# Format our bcc addresses to support the Name field
bcc = [formataddr(
(self.names.get(addr, False), addr), charset='utf-8')
for addr in bcc]
self.logger.debug('Email From: {} <{}>'.format(
quote(reply_to[0], ' '),
@ -436,23 +419,14 @@ class NotifySES(NotifyBase):
# Create a Multipart container if there is an attachment
base = MIMEMultipart() if attach else content
# TODO: Deduplicate with `NotifyEmail`?
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')
if reply_to[1] != self.from_addr:
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['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')
if reply_to[1] != self.from_addr:
base['Reply-To'] = formataddr(reply_to, charset='utf-8')
base['Cc'] = ','.join(cc)
base['Date'] = \
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))
return False
try:
sender = formataddr(
(self.from_name if self.from_name else False,
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))
sender = formataddr(
(self.from_name if self.from_name else False,
self.from_addr), charset='utf-8')
# Prepare our payload
payload = {
@ -369,33 +361,17 @@ class NotifySMTP2Go(NotifyBase):
# Strip target out of bcc list if in To
bcc = (bcc - set([to_addr[1]]))
try:
# Prepare our to
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`
to.append(formataddr(to_addr, charset='utf-8'))
# Prepare our To
payload['to'] = to
if cc:
try:
# Format our cc addresses to support the Name field
payload['cc'] = [formataddr(
(self.names.get(addr, False), addr), charset='utf-8')
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 cc addresses to support the Name field
payload['cc'] = [formataddr(
(self.names.get(addr, False), addr), charset='utf-8')
for addr in cc]
# Format our bcc addresses to support the Name field
if bcc:

View file

@ -1,6 +1,6 @@
# -*- coding: utf-8 -*-
#
# Copyright (C) 2020 Chris Caron <lead2gold@gmail.com>
# Copyright (C) 2022 Chris Caron <lead2gold@gmail.com>
# All rights reserved.
#
# 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
# THE SOFTWARE.
import re
import requests
from json import dumps
import base64
from .NotifyBase import NotifyBase
from ..common import NotifyType
@ -35,6 +37,10 @@ from ..URLBase import PrivacyMode
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):
"""
A wrapper for SignalAPI Notifications
@ -113,6 +119,13 @@ class NotifySignalAPI(NotifyBase):
'regex': (r'^[0-9\s)(+-]+$', 'i'),
'map_to': 'targets',
},
'target_channel': {
'name': _('Target Group ID'),
'type': 'string',
'prefix': '@',
'regex': (r'^[a-z0-9_=-]+$', 'i'),
'map_to': 'targets',
},
'targets': {
'name': _('Targets'),
'type': 'list:string',
@ -173,23 +186,33 @@ class NotifySignalAPI(NotifyBase):
for target in parse_phone_no(targets):
# Validate targets and drop bad ones:
result = is_phone_no(target)
if not result:
self.logger.warning(
'Dropped invalid phone # '
'({}) specified.'.format(target),
)
self.invalid_targets.append(target)
if result:
# store valid phone number
self.targets.append('+{}'.format(result['full']))
continue
# store valid phone number
self.targets.append('+{}'.format(result['full']))
result = GROUP_REGEX.match(target)
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:
# Send a message to ourselves
self.targets.append(self.source)
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
"""
@ -203,12 +226,50 @@ class NotifySignalAPI(NotifyBase):
# 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
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
headers = {
'User-Agent': self.app_id,
'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
payload = {
'message': "{}{}".format(
@ -218,6 +279,10 @@ class NotifySignalAPI(NotifyBase):
"recipients": []
}
if attachments:
# Store our attachments
payload['base64_attachments'] = attachments
# Determine Authentication
auth = None
if self.user:
@ -255,7 +320,8 @@ class NotifySignalAPI(NotifyBase):
verify=self.verify_certificate,
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
status_str = \
NotifySignalAPI.http_response_code_lookup(
@ -339,7 +405,11 @@ class NotifySignalAPI(NotifyBase):
targets = self.invalid_targets
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(
schema=self.secure_protocol if self.secure else self.protocol,
@ -350,7 +420,7 @@ class NotifySignalAPI(NotifyBase):
else ':{}'.format(self.port),
src=self.source,
dst='/'.join(
[NotifySignalAPI.quote(x, safe='') for x in targets]),
[NotifySignalAPI.quote(x, safe='@+') for x in targets]),
params=NotifySignalAPI.urlencode(params),
)

View file

@ -33,7 +33,6 @@
# from). Activated phone numbers can be found on your dashboard here:
# - https://dashboard.sinch.com/numbers/your-numbers/numbers
#
import six
import requests
import json
@ -46,7 +45,7 @@ from ..utils import validate_regex
from ..AppriseLocale import gettext_lazy as _
class SinchRegion(object):
class SinchRegion:
"""
Defines the Sinch Server Regions
"""
@ -192,7 +191,7 @@ class NotifySinch(NotifyBase):
# Setup our region
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:
msg = 'The region specified ({}) is invalid.'.format(region)
self.logger.warning(msg)

View file

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

View file

@ -80,7 +80,7 @@ SPARKPOST_HTTP_ERROR_MAP = {
# Priorities
class SparkPostRegion(object):
class SparkPostRegion:
US = 'us'
EU = 'eu'
@ -503,14 +503,9 @@ class NotifySparkPost(NotifyBase):
# Send in batches if identified to do so
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,
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))
reply_to = formataddr((self.from_name if self.from_name else False,
self.from_addr), charset='utf-8')
payload = {
"options": {
# 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
# Finally the channel identifier is detected
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)

View file

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

View file

@ -23,7 +23,6 @@
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
# THE SOFTWARE.
import os
import six
import syslog
import socket
@ -101,7 +100,7 @@ SYSLOG_FACILITY_RMAP = {
}
class SyslogMode(object):
class SyslogMode:
# A local query
LOCAL = "local"
@ -217,7 +216,7 @@ class NotifySyslog(NotifyBase):
self.template_tokens['facility']['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:
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
# search the body for these sequences and convert them to the
# output the user expected
__telegram_escape_html_dict = {
# New Lines
re.compile(r'<\s*/?br\s*/?>\r*\n?', re.I): '\r\n',
re.compile(r'<\s*/(br|p|div|li)[^>]*>\r*\n?', re.I): '\r\n',
# 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>',
__telegram_escape_html_entries = (
# Comments
(re.compile(
r'\s*<!.+?-->\s*',
(re.I | re.M | re.S)), '', {}),
# the following tags are not supported
re.compile(
r'<\s*(br|p|div|span|body|script|meta|html|font'
r'|label|iframe|li|ol|ul|source|script)[^>]*>', re.I): '',
(re.compile(
r'\s*<\s*(!?DOCTYPE|p|div|span|body|script|link|'
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(
r'<\s*/(span|body|script|meta|html|font'
r'|label|iframe|ol|ul|source|script)[^>]*>', re.I): '',
# Italic
re.compile(r'<\s*(caption|em)[^>]*>', re.I): '<i>',
re.compile(r'<\s*/(caption|em)[^>]*>', re.I): '</i>',
# All closing tags to be removed are put here
(re.compile(
r'\s*<\s*/(span|body|script|meta|html|font|head|'
r'label|form|input|textarea|select|ol|ul|link|'
r'iframe|source|script)([^a-z0-9>][^>]*)?>\s*',
(re.I | re.M | re.S)), '', {}),
# Bold
re.compile(r'<\s*(h[1-6]|title|strong)[^>]*>', re.I): '<b>',
re.compile(r'<\s*/(h[1-6]|title|strong)[^>]*>', re.I): '</b>',
(re.compile(
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
# 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
re.compile(r'\&emsp;?', re.I): ' ',
(re.compile(r'\&emsp;?', re.I), ' ', {}),
# Some characters get re-escaped by the Telegram upstream
# service so we need to convert these back,
re.compile(r'\&apos;?', re.I): '\'',
re.compile(r'\&quot;?', re.I): '"',
}
(re.compile(r'\&apos;?', 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
template_tokens = dict(NotifyBase.template_tokens, **{
@ -597,38 +638,19 @@ class NotifyTelegram(NotifyBase):
# Use Telegram's HTML mode
payload['parse_mode'] = 'HTML'
for r, v in self.__telegram_escape_html_dict.items():
body = r.sub(v, body, re.I)
for r, v, m in self.__telegram_escape_html_entries:
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
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
targets = list(self.targets)
while len(targets):

View file

@ -785,7 +785,7 @@ class NotifyTwist(NotifyBase):
def __del__(self):
"""
Deconstructor
Destructor
"""
try:
self.logout()
@ -808,14 +808,14 @@ class NotifyTwist(NotifyBase):
except ImportError: # pragma: no cover
# 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
# 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
# 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.
# https://stackoverflow.com/questions/67218341/\
@ -827,6 +827,6 @@ class NotifyTwist(NotifyBase):
# /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)
# 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.
pass

View file

@ -26,7 +26,6 @@
# See https://developer.twitter.com/en/docs/direct-messages/\
# sending-and-receiving/api-reference/new-event.html
import re
import six
import requests
from copy import deepcopy
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)
class TwitterMessageMode(object):
class TwitterMessageMode:
"""
Twitter Message Mode
"""
@ -223,7 +222,7 @@ class NotifyTwitter(NotifyBase):
# Store our webhook mode
self.mode = None \
if not isinstance(mode, six.string_types) else mode.lower()
if not isinstance(mode, str) else mode.lower()
# Set Cache Flag
self.cache = cache

View file

@ -39,24 +39,24 @@ from ..utils import validate_regex
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
service_name = 'Nexmo'
service_name = 'Vonage'
# The services URL
service_url = 'https://dashboard.nexmo.com/'
# The default protocol
secure_protocol = 'nexmo'
# The default protocol (nexmo kept for backwards compatibility)
secure_protocol = ('vonage', 'nexmo')
# A URL that takes you to the setup/help of the specific protocol
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'
# The maximum length of the body
@ -124,7 +124,7 @@ class NotifyNexmo(NotifyBase):
},
# 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
# hours. We recommend this value should be kept at its default or at
# least 30 minutes.
@ -140,15 +140,15 @@ class NotifyNexmo(NotifyBase):
def __init__(self, apikey, secret, source, targets=None, ttl=None,
**kwargs):
"""
Initialize Nexmo Object
Initialize Vonage Object
"""
super(NotifyNexmo, self).__init__(**kwargs)
super(NotifyVonage, self).__init__(**kwargs)
# API Key (associated with project)
self.apikey = validate_regex(
apikey, *self.template_tokens['apikey']['regex'])
if not self.apikey:
msg = 'An invalid Nexmo API Key ' \
msg = 'An invalid Vonage API Key ' \
'({}) was specified.'.format(apikey)
self.logger.warning(msg)
raise TypeError(msg)
@ -157,7 +157,7 @@ class NotifyNexmo(NotifyBase):
self.secret = validate_regex(
secret, *self.template_tokens['secret']['regex'])
if not self.secret:
msg = 'An invalid Nexmo API Secret ' \
msg = 'An invalid Vonage API Secret ' \
'({}) was specified.'.format(secret)
self.logger.warning(msg)
raise TypeError(msg)
@ -173,7 +173,7 @@ class NotifyNexmo(NotifyBase):
if self.ttl < self.template_args['ttl']['min'] or \
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)
self.logger.warning(msg)
raise TypeError(msg)
@ -211,7 +211,7 @@ class NotifyNexmo(NotifyBase):
def send(self, body, title='', notify_type=NotifyType.INFO, **kwargs):
"""
Perform Nexmo Notification
Perform Vonage Notification
"""
# error tracking (used for function return)
@ -250,9 +250,9 @@ class NotifyNexmo(NotifyBase):
payload['to'] = target
# 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.logger.debug('Nexmo Payload: {}' .format(payload))
self.logger.debug('Vonage Payload: {}' .format(payload))
# Always call throttle before any remote server i/o is made
self.throttle()
@ -269,11 +269,11 @@ class NotifyNexmo(NotifyBase):
if r.status_code != requests.codes.ok:
# We had a problem
status_str = \
NotifyNexmo.http_response_code_lookup(
NotifyVonage.http_response_code_lookup(
r.status_code)
self.logger.warning(
'Failed to send Nexmo notification to {}: '
'Failed to send Vonage notification to {}: '
'{}{}error={}.'.format(
target,
status_str,
@ -288,11 +288,12 @@ class NotifyNexmo(NotifyBase):
continue
else:
self.logger.info('Sent Nexmo notification to %s.' % target)
self.logger.info(
'Sent Vonage notification to %s.' % target)
except requests.RequestException as e:
self.logger.warning(
'A Connection error occurred sending Nexmo:%s '
'A Connection error occurred sending Vonage:%s '
'notification.' % target
)
self.logger.debug('Socket Exception: %s' % str(e))
@ -317,14 +318,14 @@ class NotifyNexmo(NotifyBase):
params.update(self.url_parameters(privacy=privacy, *args, **kwargs))
return '{schema}://{key}:{secret}@{source}/{targets}/?{params}'.format(
schema=self.secure_protocol,
schema=self.secure_protocol[0],
key=self.pprint(self.apikey, privacy, safe=''),
secret=self.pprint(
self.secret, privacy, mode=PrivacyMode.Secret, safe=''),
source=NotifyNexmo.quote(self.source, safe=''),
source=NotifyVonage.quote(self.source, safe=''),
targets='/'.join(
[NotifyNexmo.quote(x, safe='') for x in self.targets]),
params=NotifyNexmo.urlencode(params))
[NotifyVonage.quote(x, safe='') for x in self.targets]),
params=NotifyVonage.urlencode(params))
@staticmethod
def parse_url(url):
@ -340,46 +341,46 @@ class NotifyNexmo(NotifyBase):
# Get our entries; split_path() looks after unquoting content for us
# by default
results['targets'] = NotifyNexmo.split_path(results['fullpath'])
results['targets'] = NotifyVonage.split_path(results['fullpath'])
# 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
results['apikey'] = NotifyNexmo.unquote(results['user'])
results['secret'] = NotifyNexmo.unquote(results['password'])
results['apikey'] = NotifyVonage.unquote(results['user'])
results['secret'] = NotifyVonage.unquote(results['password'])
# API Key
if 'key' in results['qsd'] and len(results['qsd']['key']):
# Extract the API Key from an argument
results['apikey'] = \
NotifyNexmo.unquote(results['qsd']['key'])
NotifyVonage.unquote(results['qsd']['key'])
# API Secret
if 'secret' in results['qsd'] and len(results['qsd']['secret']):
# Extract the API Secret from an argument
results['secret'] = \
NotifyNexmo.unquote(results['qsd']['secret'])
NotifyVonage.unquote(results['qsd']['secret'])
# 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'] = \
NotifyNexmo.unquote(results['qsd']['from'])
NotifyVonage.unquote(results['qsd']['from'])
if 'source' in results['qsd'] and len(results['qsd']['source']):
results['source'] = \
NotifyNexmo.unquote(results['qsd']['source'])
NotifyVonage.unquote(results['qsd']['source'])
# Support the 'ttl' variable
if 'ttl' in results['qsd'] and len(results['qsd']['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
# The 'to' makes it easier to use yaml configuration
if 'to' in results['qsd'] and len(results['qsd']['to']):
results['targets'] += \
NotifyNexmo.parse_phone_no(results['qsd']['to'])
NotifyVonage.parse_phone_no(results['qsd']['to'])
return results

View file

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

View file

@ -24,7 +24,6 @@
# THE SOFTWARE.
import re
import six
import requests
import base64
@ -157,11 +156,11 @@ class NotifyXML(NotifyBase):
</soapenv:Envelope>"""
self.fullpath = kwargs.get('fullpath')
if not isinstance(self.fullpath, six.string_types):
if not isinstance(self.fullpath, str):
self.fullpath = ''
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:
msg = 'The method specified ({}) is invalid.'.format(method)
@ -284,8 +283,7 @@ class NotifyXML(NotifyBase):
try:
with open(attachment.path, 'rb') as f:
# Output must be in a DataURL format (that's what
# PushSafer calls it):
# Prepare our Attachment in Base64
entry = \
'<Attachment filename="{}" mimetype="{}">'.format(
NotifyXML.escape_html(
@ -415,17 +413,9 @@ class NotifyXML(NotifyBase):
for x, y in results['qsd:'].items()}
# Add our headers that the user can potentially over-ride if they wish
# to to our returned result set
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
# to to our returned result set and tidy entries by unquoting them
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
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 validate_regex
from ..utils import is_email
from ..utils import remove_suffix
from ..AppriseLocale import gettext_lazy as _
# A Valid Bot Name
VALIDATE_BOTNAME = re.compile(r'(?P<name>[A-Z0-9_]{1,32})(-bot)?', re.I)
VALIDATE_BOTNAME = re.compile(r'(?P<name>[A-Z0-9_-]{1,32})', re.I)
# Organization required as part of the API request
VALIDATE_ORG = re.compile(
@ -122,7 +123,7 @@ class NotifyZulip(NotifyBase):
'botname': {
'name': _('Bot Name'),
'type': 'string',
'regex': (r'^[A-Z0-9_]{1,32}(-bot)?$', 'i'),
'regex': (r'^[A-Z0-9_-]{1,32}$', 'i'),
},
'organization': {
'name': _('Organization'),
@ -183,7 +184,9 @@ class NotifyZulip(NotifyBase):
raise TypeError
# The botname
self.botname = match.group('name')
botname = match.group('name')
botname = remove_suffix(botname, '-bot')
self.botname = botname
except (TypeError, AttributeError):
msg = 'The Zulip botname specified ({}) is invalid.'\

View file

@ -24,7 +24,6 @@
# THE SOFTWARE.
import os
import six
import re
import copy
@ -33,7 +32,6 @@ from os.path import abspath
# Used for testing
from . import NotifyEmail as NotifyEmailBase
from .NotifyXMPP import SliXmppAdapter
# NotifyBase object is passed in as a module not class
from . import NotifyBase
@ -42,6 +40,7 @@ from ..common import NotifyImageSize
from ..common import NOTIFY_IMAGE_SIZES
from ..common import NotifyType
from ..common import NOTIFY_TYPES
from .. import common
from ..utils import parse_list
from ..utils import cwe312_url
from ..utils import GET_SCHEMA_RE
@ -49,9 +48,6 @@ from ..logger import logger
from ..AppriseLocale import gettext_lazy as _
from ..AppriseLocale import LazyTranslation
# Maintains a mapping of all of the Notification services
SCHEMA_MAP = {}
__all__ = [
# Reference
'NotifyImageSize', 'NOTIFY_IMAGE_SIZES', 'NotifyType', 'NOTIFY_TYPES',
@ -62,15 +58,8 @@ __all__ = [
# Tokenizer
'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
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
continue
elif plugin_name in MODULE_MAP:
elif plugin_name in common.NOTIFY_MODULE_MAP:
# we're already handling this object
continue
# Add our plugin name to our module map
MODULE_MAP[plugin_name] = {
common.NOTIFY_MODULE_MAP[plugin_name] = {
'plugin': plugin,
'module': module,
}
@ -130,40 +119,20 @@ def __load_matrix(path=abspath(dirname(__file__)), name='apprise.plugins'):
globals()[plugin_name] = plugin
fn = getattr(plugin, 'schemas', None)
try:
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)
schemas = set([]) if not callable(fn) else fn(plugin)
# map our schema to our plugin
for schema in schemas:
if schema in SCHEMA_MAP:
if schema in common.NOTIFY_SCHEMA_MAP:
logger.error(
"Notification schema ({}) mismatch detected - {} to {}"
.format(schema, SCHEMA_MAP[schema], plugin))
.format(schema, common.NOTIFY_SCHEMA_MAP[schema], plugin))
continue
# Assign plugin
SCHEMA_MAP[schema] = plugin
common.NOTIFY_SCHEMA_MAP[schema] = plugin
return SCHEMA_MAP
return common.NOTIFY_SCHEMA_MAP
# Reset our Lookup Matrix
@ -174,10 +143,10 @@ def __reset_matrix():
"""
# 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
for plugin_name in MODULE_MAP.keys():
for plugin_name in common.NOTIFY_MODULE_MAP.keys():
# Clear out globals
del globals()[plugin_name]
@ -185,7 +154,7 @@ def __reset_matrix():
__all__.remove(plugin_name)
# Clear out our module map
MODULE_MAP.clear()
common.NOTIFY_MODULE_MAP.clear()
# Dynamically build our schema base
@ -242,7 +211,7 @@ def _sanitize_token(tokens, default_delimiter):
if 'regex' in tokens[key]:
# 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
tokens[key]['regex'] = \
(tokens[key]['regex'], None)
@ -483,7 +452,7 @@ def requirements(plugin):
# Get our required packages
_req_packages = plugin.requirements.get('packages_required')
if isinstance(_req_packages, six.string_types):
if isinstance(_req_packages, str):
# Convert to list
_req_packages = [_req_packages]
@ -495,7 +464,7 @@ def requirements(plugin):
# Get our recommended packages
_opt_packages = plugin.requirements.get('packages_recommended')
if isinstance(_opt_packages, six.string_types):
if isinstance(_opt_packages, str):
# Convert to list
_opt_packages = [_opt_packages]
@ -554,14 +523,14 @@ def url_to_dict(url, secure_logging=True):
# Ensure our schema is always in lower case
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
# one of the URLs provided to them by their notification service.
# Before we fail for good, just scan all the plugins that support the
# native_url() parse function
results = \
next((r['plugin'].parse_native_url(_url)
for r in MODULE_MAP.values()
for r in common.NOTIFY_MODULE_MAP.values()
if r['plugin'].parse_native_url(_url) is not None),
None)
@ -576,14 +545,14 @@ def url_to_dict(url, secure_logging=True):
else:
# Parse our url details of the server object as dictionary
# containing all of the information parsed from our URL
results = SCHEMA_MAP[schema].parse_url(_url)
results = common.NOTIFY_SCHEMA_MAP[schema].parse_url(_url)
if not results:
logger.error('Unparseable {} URL {}'.format(
SCHEMA_MAP[schema].service_name, loggable_url))
common.NOTIFY_SCHEMA_MAP[schema].service_name, loggable_url))
return None
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(
['{}="{}"'.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)
# async reference produces a SyntaxError (E999) in Python v2.7
# For this reason we turn on the noqa flag
async def notify(coroutines): # noqa: E999
async def notify(coroutines):
"""
An async wrapper to the AsyncNotifyBase.async_notify() calls allowing us
to call gather() and collect the responses
@ -63,7 +61,20 @@ def tosync(cor, debug=False):
"""
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:
# The Deprecated Way (<= Python v3.6)
@ -85,7 +96,7 @@ def tosync(cor, debug=False):
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.
"""
@ -93,12 +104,20 @@ async def toasyncwrap(v): # noqa: E999
return v
async def toasyncwrap(fn):
"""
Create a coroutine that, when run, executes the provided function.
"""
return fn()
class AsyncNotifyBase(URLBase):
"""
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
"""
@ -110,11 +129,11 @@ class AsyncNotifyBase(URLBase):
None, partial(self.notify, *args, **kwargs))
except TypeError:
# These our our internally thrown notifications
# These are our internally thrown notifications
pass
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.
logger.exception("Notification Exception")

View file

@ -24,27 +24,55 @@
# THE SOFTWARE.
import re
import six
import sys
import json
import contextlib
import os
import hashlib
from itertools import chain
from os.path import expanduser
from functools import reduce
from .common import MATCH_ALL_TAG
from .common import MATCH_ALWAYS_TAG
from . import common
from .logger import logger
try:
# Python 2.7
from urllib import unquote
from urllib import quote
from urlparse import urlparse
from urllib.parse import unquote
from urllib.parse import quote
from urllib.parse import urlparse
from urllib.parse import urlencode as _urlencode
except ImportError:
# Python 3.x
from urllib.parse import unquote
from urllib.parse import quote
from urllib.parse import urlparse
import importlib.util
def import_module(path, name):
"""
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()
# 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.
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:
# http://www.regular-expressions.info/email.html
# Extended to support colon (:) delimiter for parsing names from the URL
@ -167,7 +202,7 @@ UUID4_RE = re.compile(
REGEX_VALIDATE_LOOKUP = {}
class TemplateType(object):
class TemplateType:
"""
Defines the different template types we can perform parsing on
"""
@ -294,7 +329,7 @@ def is_uuid(uuid):
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
Args:
@ -477,16 +512,14 @@ def tidy_path(path):
# Windows
path = TIDY_WIN_PATH_RE.sub('\\1', path.strip())
# Linux
path = TIDY_NUX_PATH_RE.sub('\\1', path.strip())
path = TIDY_NUX_PATH_RE.sub('\\1', path)
# Linux Based Trim
path = TIDY_NUX_TRIM_RE.sub('\\1', path.strip())
# Windows Based Trim
path = expanduser(TIDY_WIN_TRIM_RE.sub('\\1', path.strip()))
# Windows Based (final) Trim
path = expanduser(TIDY_WIN_TRIM_RE.sub('\\1', path))
return path
def parse_qsd(qs):
def parse_qsd(qs, simple=False):
"""
Query String Dictionary Builder
@ -505,6 +538,9 @@ def parse_qsd(qs):
This function returns a result object that fits with the apprise
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:
@ -520,7 +556,7 @@ def parse_qsd(qs):
'qsd+': {},
'qsd-': {},
'qsd:': {},
}
} if not simple else {'qsd': {}}
pairs = [s2 for s1 in qs.split('&') for s2 in s1.split(';')]
for name_value in pairs:
@ -547,6 +583,10 @@ def parse_qsd(qs):
# content is always made lowercase for easy indexing
result['qsd'][key.lower().strip()] = val
if simple:
# move along
continue
# Check for tokens that start with a addition/plus symbol (+)
k = NOTIFY_CUSTOM_ADD_TOKENS.match(key)
if k is not None:
@ -568,7 +608,8 @@ def parse_qsd(qs):
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
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 parsed content within it and returns 'None' if the
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
return None
@ -628,7 +702,7 @@ def parse_url(url, default_schema='http', verify_host=True, strict_port=False):
'qsd+': {},
'qsd-': {},
'qsd:': {},
}
} if not simple else {}
qsdata = ''
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
# while ensuring that all keys are lowercase
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
# 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
if not result['fullpath']:
# Default
result['fullpath'] = None
if not simple:
# Default
result['fullpath'] = None
else:
# Remove entry
del result['fullpath']
else:
# 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['query'] = match.group('query')
if not result['query']:
result['query'] = None
if not simple:
result['query'] = None
else:
del result['query']
try:
(result['user'], result['host']) = \
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
pass
if result['user'] is not None:
if result.get('user') is not None:
try:
(result['user'], result['password']) = \
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
return None
# Acquire our port (if defined)
_port = result.get('port')
if verify_host:
# Verify and Validate our hostname
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
# 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 (
strict_port and
result['port'] > 0 and result['port'] <= 65535))):
strict_port and _port > 0 and _port <= 65535))):
# An invalid port was specified
return None
elif pmatch and not isinstance(result['port'], int):
elif pmatch and not isinstance(_port, int):
if strict_port:
# Store port
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
result['url'] = '%s://' % result['schema']
if isinstance(result['user'], six.string_types):
if isinstance(result.get('user'), str):
result['url'] += result['user']
if isinstance(result['password'], six.string_types):
if isinstance(result.get('password'), str):
result['url'] += ':%s@' % result['password']
else:
result['url'] += '@'
result['url'] += result['host']
if result['port'] is not None:
if result.get('port') is not None:
try:
result['url'] += ':%d' % result['port']
except TypeError:
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']
if simple and not result['host']:
# simple mode does not carry over empty host names
del result['host']
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 isinstance(arg, six.string_types):
if isinstance(arg, str):
# no = no - False
# of = short for off - False
# 0 = int for False
@ -814,20 +906,15 @@ def parse_bool(arg, default=False):
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
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 = []
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)
if _result:
result += _result
@ -851,20 +938,15 @@ def parse_phone_no(*args, **kwargs):
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
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 = []
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)
if _result:
result += _result
@ -888,20 +970,15 @@ def parse_call_sign(*args, **kwargs):
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
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 = []
for arg in args:
if isinstance(arg, six.string_types) and arg:
if isinstance(arg, str) and arg:
_result = EMAIL_DETECTION_RE.findall(arg)
if _result:
result += _result
@ -924,20 +1001,15 @@ def parse_emails(*args, **kwargs):
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
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 = []
for arg in args:
if isinstance(arg, six.string_types) and arg:
if isinstance(arg, str) and arg:
_result = URL_DETECTION_RE.findall(arg)
if _result:
result += _result
@ -960,6 +1032,75 @@ def parse_urls(*args, **kwargs):
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):
"""
Take a string list and break it into a delimited
@ -983,7 +1124,7 @@ def parse_list(*args):
result = []
for arg in args:
if isinstance(arg, six.string_types):
if isinstance(arg, str):
result += re.split(STRING_DELIMITERS, arg)
elif isinstance(arg, (set, list, tuple)):
@ -992,14 +1133,15 @@ def parse_list(*args):
#
# 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
# both distribution types.
# TODO: Review after dropping support for Python 2.
return sorted([x for x in filter(bool, list(set(result)))])
def is_exclusive_match(logic, data, match_all=MATCH_ALL_TAG,
match_always=MATCH_ALWAYS_TAG):
def is_exclusive_match(logic, data, match_all=common.MATCH_ALL_TAG,
match_always=common.MATCH_ALWAYS_TAG):
"""
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.
"""
if isinstance(logic, six.string_types):
if isinstance(logic, str):
# Update our logic to support our delimiters
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
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
return False
@ -1109,7 +1251,7 @@ def validate_regex(value, regex=r'[^\s]+', flags=re.I, strip=True, fmt=None):
'x': re.X,
}
if isinstance(flags, six.string_types):
if isinstance(flags, str):
# Convert a string of regular expression flags into their
# respected integer (expected) Python values and perform
# 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
"""
class Variance(object):
class Variance:
"""
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)
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
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
# re-index it back into our list
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
aniso8601==9.0.1
argparse==1.4.0
apprise==0.9.8.3
apprise==1.1.0
apscheduler==3.8.1
attrs==22.1.0
charamel==1.0.0