mirror of
https://github.com/morpheus65535/bazarr.git
synced 2025-04-23 22:27:17 -04:00
Updated apprise to the latest version. #1834
This commit is contained in:
parent
fcd67c1fb0
commit
1dff555fc8
34 changed files with 5044 additions and 1331 deletions
|
@ -23,14 +23,13 @@
|
|||
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
# THE SOFTWARE.
|
||||
|
||||
import re
|
||||
import os
|
||||
import six
|
||||
from markdown import markdown
|
||||
from itertools import chain
|
||||
from .common import NotifyType
|
||||
from .common import NotifyFormat
|
||||
from .common import MATCH_ALL_TAG
|
||||
from .common import MATCH_ALWAYS_TAG
|
||||
from .conversion import convert_between
|
||||
from .utils import is_exclusive_match
|
||||
from .utils import parse_list
|
||||
from .utils import parse_urls
|
||||
|
@ -44,6 +43,7 @@ from .AppriseLocale import AppriseLocale
|
|||
from .config.ConfigBase import ConfigBase
|
||||
from .plugins.NotifyBase import NotifyBase
|
||||
|
||||
|
||||
from . import plugins
|
||||
from . import __version__
|
||||
|
||||
|
@ -305,7 +305,7 @@ class Apprise(object):
|
|||
"""
|
||||
self.servers[:] = []
|
||||
|
||||
def find(self, tag=MATCH_ALL_TAG):
|
||||
def find(self, tag=MATCH_ALL_TAG, match_always=True):
|
||||
"""
|
||||
Returns an list of all servers matching against the tag specified.
|
||||
|
||||
|
@ -321,6 +321,10 @@ class Apprise(object):
|
|||
# tag=[('tagA', 'tagC'), 'tagB'] = (tagA and tagC) or tagB
|
||||
# tag=[('tagB', 'tagC')] = tagB and tagC
|
||||
|
||||
# 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
|
||||
|
||||
# Iterate over our loaded plugins
|
||||
for entry in self.servers:
|
||||
|
||||
|
@ -334,13 +338,14 @@ 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=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, attach=None,
|
||||
interpret_escapes=None):
|
||||
body_format=None, tag=MATCH_ALL_TAG, match_always=True,
|
||||
attach=None, interpret_escapes=None):
|
||||
"""
|
||||
Send a notification to all of the plugins previously loaded.
|
||||
|
||||
|
@ -370,7 +375,7 @@ class Apprise(object):
|
|||
self.async_notify(
|
||||
body, title,
|
||||
notify_type=notify_type, body_format=body_format,
|
||||
tag=tag, attach=attach,
|
||||
tag=tag, match_always=match_always, attach=attach,
|
||||
interpret_escapes=interpret_escapes,
|
||||
),
|
||||
debug=self.debug
|
||||
|
@ -468,8 +473,8 @@ class Apprise(object):
|
|||
return py3compat.asyncio.toasyncwrap(status)
|
||||
|
||||
def _notifyall(self, handler, body, title='', notify_type=NotifyType.INFO,
|
||||
body_format=None, tag=MATCH_ALL_TAG, attach=None,
|
||||
interpret_escapes=None):
|
||||
body_format=None, tag=MATCH_ALL_TAG, match_always=True,
|
||||
attach=None, interpret_escapes=None):
|
||||
"""
|
||||
Creates notifications for all of the plugins loaded.
|
||||
|
||||
|
@ -480,22 +485,43 @@ class Apprise(object):
|
|||
|
||||
if len(self) == 0:
|
||||
# Nothing to notify
|
||||
raise TypeError("No service(s) to notify")
|
||||
msg = "There are service(s) to notify"
|
||||
logger.error(msg)
|
||||
raise TypeError(msg)
|
||||
|
||||
if not (title or body):
|
||||
raise TypeError("No message content specified to deliver")
|
||||
msg = "No message content specified to deliver"
|
||||
logger.error(msg)
|
||||
raise TypeError(msg)
|
||||
|
||||
if six.PY2:
|
||||
# Python 2.7.x Unicode Character Handling
|
||||
# Ensure we're working with utf-8
|
||||
if isinstance(title, unicode): # noqa: F821
|
||||
title = title.encode('utf-8')
|
||||
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 isinstance(body, unicode): # noqa: F821
|
||||
body = body.encode('utf-8')
|
||||
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)
|
||||
|
||||
except UnicodeDecodeError:
|
||||
msg = 'The content passed into Apprise was not of encoding ' \
|
||||
'type: {}'.format(self.asset.encoding)
|
||||
logger.error(msg)
|
||||
raise TypeError(msg)
|
||||
|
||||
# Tracks conversions
|
||||
conversion_map = dict()
|
||||
conversion_body_map = dict()
|
||||
conversion_title_map = dict()
|
||||
|
||||
# Prepare attachments if required
|
||||
if attach is not None and not isinstance(attach, AppriseAttachment):
|
||||
|
@ -511,86 +537,45 @@ class Apprise(object):
|
|||
if interpret_escapes is None else interpret_escapes
|
||||
|
||||
# Iterate over our loaded plugins
|
||||
for server in self.find(tag):
|
||||
for server in self.find(tag, match_always=match_always):
|
||||
# If our code reaches here, we either did not define a tag (it
|
||||
# was set to None), or we did define a tag and the logic above
|
||||
# determined we need to notify the service it's associated with
|
||||
if server.notify_format not in conversion_map:
|
||||
if body_format == NotifyFormat.MARKDOWN and \
|
||||
server.notify_format == NotifyFormat.HTML:
|
||||
if server.notify_format not in conversion_body_map:
|
||||
# Perform Conversion
|
||||
conversion_body_map[server.notify_format] = \
|
||||
convert_between(
|
||||
body_format, server.notify_format, content=body)
|
||||
|
||||
# Apply Markdown
|
||||
conversion_map[server.notify_format] = markdown(body)
|
||||
# Prepare our title
|
||||
conversion_title_map[server.notify_format] = \
|
||||
'' if not title else title
|
||||
|
||||
elif body_format == NotifyFormat.TEXT and \
|
||||
server.notify_format == NotifyFormat.HTML:
|
||||
# Tidy Title IF required (hence it will become part of the
|
||||
# body)
|
||||
if server.title_maxlen <= 0 and \
|
||||
conversion_title_map[server.notify_format]:
|
||||
|
||||
# Basic TEXT to HTML format map; supports keys only
|
||||
re_map = {
|
||||
# Support Ampersand
|
||||
r'&': '&',
|
||||
conversion_title_map[server.notify_format] = \
|
||||
convert_between(
|
||||
body_format, server.notify_format,
|
||||
content=conversion_title_map[server.notify_format])
|
||||
|
||||
# Spaces to for formatting purposes since
|
||||
# multiple spaces are treated as one an this may
|
||||
# not be the callers intention
|
||||
r' ': ' ',
|
||||
if interpret_escapes:
|
||||
#
|
||||
# Escape our content
|
||||
#
|
||||
|
||||
# Tab support
|
||||
r'\t': ' ',
|
||||
|
||||
# Greater than and Less than Characters
|
||||
r'>': '>',
|
||||
r'<': '<',
|
||||
}
|
||||
|
||||
# Compile our map
|
||||
re_table = re.compile(
|
||||
r'(' + '|'.join(
|
||||
map(re.escape, re_map.keys())) + r')',
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
# Execute our map against our body in addition to
|
||||
# swapping out new lines and replacing them with <br/>
|
||||
conversion_map[server.notify_format] = \
|
||||
re.sub(r'\r*\n', '<br/>\r\n',
|
||||
re_table.sub(
|
||||
lambda x: re_map[x.group()], body))
|
||||
|
||||
else:
|
||||
# Store entry directly
|
||||
conversion_map[server.notify_format] = body
|
||||
|
||||
if interpret_escapes:
|
||||
#
|
||||
# Escape our content
|
||||
#
|
||||
|
||||
try:
|
||||
# Added overhead required due to Python 3 Encoding Bug
|
||||
# identified here: https://bugs.python.org/issue21331
|
||||
conversion_map[server.notify_format] = \
|
||||
conversion_map[server.notify_format]\
|
||||
.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_map[server.notify_format] = \
|
||||
conversion_map[server.notify_format] \
|
||||
.decode('string_escape')
|
||||
|
||||
except AttributeError:
|
||||
# Must be of string type
|
||||
logger.error('Failed to escape message body')
|
||||
raise TypeError
|
||||
|
||||
if title:
|
||||
try:
|
||||
# Added overhead required due to Python 3 Encoding Bug
|
||||
# identified here: https://bugs.python.org/issue21331
|
||||
title = title\
|
||||
conversion_body_map[server.notify_format] = \
|
||||
conversion_body_map[server.notify_format]\
|
||||
.encode('ascii', 'backslashreplace')\
|
||||
.decode('unicode-escape')
|
||||
|
||||
conversion_title_map[server.notify_format] = \
|
||||
conversion_title_map[server.notify_format]\
|
||||
.encode('ascii', 'backslashreplace')\
|
||||
.decode('unicode-escape')
|
||||
|
||||
|
@ -598,19 +583,46 @@ class Apprise(object):
|
|||
# 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).
|
||||
title = title.decode('string_escape')
|
||||
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
|
||||
logger.error('Failed to escape message title')
|
||||
raise TypeError
|
||||
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_map[server.notify_format],
|
||||
title=title,
|
||||
body=conversion_body_map[server.notify_format],
|
||||
title=conversion_title_map[server.notify_format],
|
||||
notify_type=notify_type,
|
||||
attach=attach
|
||||
attach=attach,
|
||||
body_format=body_format,
|
||||
)
|
||||
|
||||
def details(self, lang=None, show_requirements=False, show_disabled=False):
|
||||
|
|
|
@ -58,6 +58,14 @@ class AppriseAsset(object):
|
|||
NotifyType.WARNING: '#CACF29',
|
||||
}
|
||||
|
||||
# Ascii Notification
|
||||
ascii_notify_map = {
|
||||
NotifyType.INFO: '[i]',
|
||||
NotifyType.SUCCESS: '[+]',
|
||||
NotifyType.FAILURE: '[!]',
|
||||
NotifyType.WARNING: '[~]',
|
||||
}
|
||||
|
||||
# The default color to return if a mapping isn't found in our table above
|
||||
default_html_color = '#888888'
|
||||
|
||||
|
@ -110,6 +118,9 @@ class AppriseAsset(object):
|
|||
# to a new line.
|
||||
interpret_escapes = False
|
||||
|
||||
# Defines the encoding of the content passed into Apprise
|
||||
encoding = 'utf-8'
|
||||
|
||||
# For more detail see CWE-312 @
|
||||
# https://cwe.mitre.org/data/definitions/312.html
|
||||
#
|
||||
|
@ -181,6 +192,15 @@ class AppriseAsset(object):
|
|||
raise ValueError(
|
||||
'AppriseAsset html_color(): An invalid color_type was specified.')
|
||||
|
||||
def ascii(self, notify_type):
|
||||
"""
|
||||
Returns an ascii representation based on passed in notify type
|
||||
|
||||
"""
|
||||
|
||||
# look our response up
|
||||
return self.ascii_notify_map.get(notify_type, self.default_html_color)
|
||||
|
||||
def image_url(self, notify_type, image_size, logo=False, extension=None):
|
||||
"""
|
||||
Apply our mask to our image URL
|
||||
|
|
|
@ -32,6 +32,7 @@ from . import URLBase
|
|||
from .AppriseAsset import AppriseAsset
|
||||
|
||||
from .common import MATCH_ALL_TAG
|
||||
from .common import MATCH_ALWAYS_TAG
|
||||
from .utils import GET_SCHEMA_RE
|
||||
from .utils import parse_list
|
||||
from .utils import is_exclusive_match
|
||||
|
@ -266,7 +267,7 @@ class AppriseConfig(object):
|
|||
# Return our status
|
||||
return True
|
||||
|
||||
def servers(self, tag=MATCH_ALL_TAG, *args, **kwargs):
|
||||
def servers(self, tag=MATCH_ALL_TAG, match_always=True, *args, **kwargs):
|
||||
"""
|
||||
Returns all of our servers dynamically build based on parsed
|
||||
configuration.
|
||||
|
@ -277,7 +278,15 @@ class AppriseConfig(object):
|
|||
This is for filtering the configuration files polled for
|
||||
results.
|
||||
|
||||
If the anytag is set, then any notification that is found
|
||||
set with that tag are included in the response.
|
||||
|
||||
"""
|
||||
|
||||
# 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
|
||||
|
||||
# Build our tag setup
|
||||
# - top level entries are treated as an 'or'
|
||||
# - second level (or more) entries are treated as 'and'
|
||||
|
@ -294,7 +303,8 @@ 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=MATCH_ALL_TAG,
|
||||
match_always=match_always):
|
||||
# Build ourselves a list of services dynamically and return the
|
||||
# as a list
|
||||
response.extend(entry.servers())
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
#
|
||||
# Copyright (C) 2021 Chris Caron <lead2gold@gmail.com>
|
||||
# Copyright (C) 2022 Chris Caron <lead2gold@gmail.com>
|
||||
# All rights reserved.
|
||||
#
|
||||
# This code is licensed under the MIT License.
|
||||
|
@ -24,10 +24,10 @@
|
|||
# THE SOFTWARE.
|
||||
|
||||
__title__ = 'Apprise'
|
||||
__version__ = '0.9.6'
|
||||
__version__ = '0.9.8.3'
|
||||
__author__ = 'Chris Caron'
|
||||
__license__ = 'MIT'
|
||||
__copywrite__ = 'Copyright (C) 2021 Chris Caron <lead2gold@gmail.com>'
|
||||
__copywrite__ = 'Copyright (C) 2022 Chris Caron <lead2gold@gmail.com>'
|
||||
__email__ = 'lead2gold@gmail.com'
|
||||
__status__ = 'Production'
|
||||
|
||||
|
|
|
@ -187,3 +187,7 @@ CONTENT_LOCATIONS = (
|
|||
# This is a reserved tag that is automatically assigned to every
|
||||
# Notification Plugin
|
||||
MATCH_ALL_TAG = 'all'
|
||||
|
||||
# Will cause notification to trigger under any circumstance even if an
|
||||
# exclusive tagging was provided.
|
||||
MATCH_ALWAYS_TAG = 'always'
|
||||
|
|
210
libs/apprise/conversion.py
Normal file
210
libs/apprise/conversion.py
Normal file
|
@ -0,0 +1,210 @@
|
|||
# -*- 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 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
|
||||
|
||||
|
||||
def convert_between(from_format, to_format, content):
|
||||
"""
|
||||
Converts between different suported formats. If no conversion exists,
|
||||
or the selected one fails, the original text will be returned.
|
||||
|
||||
This function returns the content translated (if required)
|
||||
"""
|
||||
|
||||
converters = {
|
||||
(NotifyFormat.MARKDOWN, NotifyFormat.HTML): markdown_to_html,
|
||||
(NotifyFormat.TEXT, NotifyFormat.HTML): text_to_html,
|
||||
(NotifyFormat.HTML, NotifyFormat.TEXT): html_to_text,
|
||||
# For now; use same converter for Markdown support
|
||||
(NotifyFormat.HTML, NotifyFormat.MARKDOWN): html_to_text,
|
||||
}
|
||||
|
||||
convert = converters.get((from_format, to_format))
|
||||
return convert(content) if convert else content
|
||||
|
||||
|
||||
def markdown_to_html(content):
|
||||
"""
|
||||
Converts specified content from markdown to HTML.
|
||||
"""
|
||||
|
||||
return markdown(content)
|
||||
|
||||
|
||||
def text_to_html(content):
|
||||
"""
|
||||
Converts specified content from plain text to HTML.
|
||||
"""
|
||||
|
||||
return URLBase.escape_html(content)
|
||||
|
||||
|
||||
def html_to_text(content):
|
||||
"""
|
||||
Converts a content from HTML to plain text.
|
||||
"""
|
||||
|
||||
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
|
||||
|
||||
|
||||
class HTMLConverter(HTMLParser, object):
|
||||
"""An HTML to plain text converter tuned for email messages."""
|
||||
|
||||
# The following tags must start on a new line
|
||||
BLOCK_TAGS = ('p', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
|
||||
'div', 'td', 'th', 'code', 'pre', 'label', 'li',)
|
||||
|
||||
# the folowing tags ignore any internal text
|
||||
IGNORE_TAGS = ('style', 'link', 'meta', 'title', 'html', 'head', 'script')
|
||||
|
||||
# Condense Whitespace
|
||||
WS_TRIM = re.compile(r'[\s]+', re.DOTALL | re.MULTILINE)
|
||||
|
||||
# Sentinel value for block tag boundaries, which may be consolidated into a
|
||||
# single line break.
|
||||
BLOCK_END = {}
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
super(HTMLConverter, self).__init__(**kwargs)
|
||||
|
||||
# Shoudl we store the text content or not?
|
||||
self._do_store = True
|
||||
|
||||
# Initialize internal result list
|
||||
self._result = []
|
||||
|
||||
# Initialize public result field (not populated until close() is
|
||||
# called)
|
||||
self.converted = ""
|
||||
|
||||
def close(self):
|
||||
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
|
||||
block ends into singleton newlines.
|
||||
|
||||
[ {be} " Hello " {be} {be} " World!" ] -> "\nHello\nWorld!"
|
||||
"""
|
||||
|
||||
# None means the last visited item was a block end.
|
||||
accum = None
|
||||
|
||||
for item in result:
|
||||
if item == self.BLOCK_END:
|
||||
# Multiple consecutive block ends; do nothing.
|
||||
if accum is None:
|
||||
continue
|
||||
|
||||
# First block end; yield the current string, plus a newline.
|
||||
yield accum.strip() + '\n'
|
||||
accum = None
|
||||
|
||||
# Multiple consecutive strings; combine them.
|
||||
elif accum is not None:
|
||||
accum += item
|
||||
|
||||
# First consecutive string; store it.
|
||||
else:
|
||||
accum = item
|
||||
|
||||
# Yield the last string if we have not already done so.
|
||||
if accum is not None:
|
||||
yield accum.strip()
|
||||
|
||||
def handle_data(self, data, *args, **kwargs):
|
||||
"""
|
||||
Store our data if it is not on the ignore list
|
||||
"""
|
||||
|
||||
# initialize our previous flag
|
||||
if self._do_store:
|
||||
|
||||
# Tidy our whitespace
|
||||
content = self.WS_TRIM.sub(' ', data)
|
||||
self._result.append(content)
|
||||
|
||||
def handle_starttag(self, tag, attrs):
|
||||
"""
|
||||
Process our starting HTML Tag
|
||||
"""
|
||||
# Toggle initial states
|
||||
self._do_store = tag not in self.IGNORE_TAGS
|
||||
|
||||
if tag in self.BLOCK_TAGS:
|
||||
self._result.append(self.BLOCK_END)
|
||||
|
||||
if tag == 'li':
|
||||
self._result.append('- ')
|
||||
|
||||
elif tag == 'br':
|
||||
self._result.append('\n')
|
||||
|
||||
elif tag == 'hr':
|
||||
if self._result:
|
||||
self._result[-1] = self._result[-1].rstrip(' ')
|
||||
|
||||
self._result.append('\n---\n')
|
||||
|
||||
elif tag == 'blockquote':
|
||||
self._result.append(' >')
|
||||
|
||||
def handle_endtag(self, tag):
|
||||
"""
|
||||
Edge case handling of open/close tags
|
||||
"""
|
||||
self._do_store = True
|
||||
|
||||
if tag in self.BLOCK_TAGS:
|
||||
self._result.append(self.BLOCK_END)
|
|
@ -1,660 +0,0 @@
|
|||
# Translations template for apprise.
|
||||
# Copyright (C) 2021 Chris Caron
|
||||
# This file is distributed under the same license as the apprise project.
|
||||
# FIRST AUTHOR <EMAIL@ADDRESS>, 2021.
|
||||
#
|
||||
#, fuzzy
|
||||
msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: apprise 0.9.6\n"
|
||||
"Report-Msgid-Bugs-To: lead2gold@gmail.com\n"
|
||||
"POT-Creation-Date: 2021-12-01 18:56-0500\n"
|
||||
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
|
||||
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
|
||||
"Language-Team: LANGUAGE <LL@li.org>\n"
|
||||
"MIME-Version: 1.0\n"
|
||||
"Content-Type: text/plain; charset=utf-8\n"
|
||||
"Content-Transfer-Encoding: 8bit\n"
|
||||
"Generated-By: Babel 2.9.1\n"
|
||||
|
||||
msgid "A local Gnome environment is required."
|
||||
msgstr ""
|
||||
|
||||
msgid "A local Microsoft Windows environment is required."
|
||||
msgstr ""
|
||||
|
||||
msgid "API Key"
|
||||
msgstr ""
|
||||
|
||||
msgid "API Secret"
|
||||
msgstr ""
|
||||
|
||||
msgid "Access Key"
|
||||
msgstr ""
|
||||
|
||||
msgid "Access Key ID"
|
||||
msgstr ""
|
||||
|
||||
msgid "Access Secret"
|
||||
msgstr ""
|
||||
|
||||
msgid "Access Token"
|
||||
msgstr ""
|
||||
|
||||
msgid "Account Email"
|
||||
msgstr ""
|
||||
|
||||
msgid "Account SID"
|
||||
msgstr ""
|
||||
|
||||
msgid "Add Tokens"
|
||||
msgstr ""
|
||||
|
||||
msgid "Alert Type"
|
||||
msgstr ""
|
||||
|
||||
msgid "Alias"
|
||||
msgstr ""
|
||||
|
||||
msgid "Amount"
|
||||
msgstr ""
|
||||
|
||||
msgid "App Access Token"
|
||||
msgstr ""
|
||||
|
||||
msgid "App ID"
|
||||
msgstr ""
|
||||
|
||||
msgid "App Version"
|
||||
msgstr ""
|
||||
|
||||
msgid "Application ID"
|
||||
msgstr ""
|
||||
|
||||
msgid "Application Key"
|
||||
msgstr ""
|
||||
|
||||
msgid "Application Secret"
|
||||
msgstr ""
|
||||
|
||||
msgid "Auth Token"
|
||||
msgstr ""
|
||||
|
||||
msgid "Authentication Key"
|
||||
msgstr ""
|
||||
|
||||
msgid "Authorization Token"
|
||||
msgstr ""
|
||||
|
||||
msgid "Avatar Image"
|
||||
msgstr ""
|
||||
|
||||
msgid "Avatar URL"
|
||||
msgstr ""
|
||||
|
||||
msgid "Batch Mode"
|
||||
msgstr ""
|
||||
|
||||
msgid "Blind Carbon Copy"
|
||||
msgstr ""
|
||||
|
||||
msgid "Bot Name"
|
||||
msgstr ""
|
||||
|
||||
msgid "Bot Token"
|
||||
msgstr ""
|
||||
|
||||
msgid "Cache Age"
|
||||
msgstr ""
|
||||
|
||||
msgid "Cache Results"
|
||||
msgstr ""
|
||||
|
||||
msgid "Call"
|
||||
msgstr ""
|
||||
|
||||
msgid "Carbon Copy"
|
||||
msgstr ""
|
||||
|
||||
msgid "Channels"
|
||||
msgstr ""
|
||||
|
||||
msgid "Client ID"
|
||||
msgstr ""
|
||||
|
||||
msgid "Client Secret"
|
||||
msgstr ""
|
||||
|
||||
msgid "Consumer Key"
|
||||
msgstr ""
|
||||
|
||||
msgid "Consumer Secret"
|
||||
msgstr ""
|
||||
|
||||
msgid "Country"
|
||||
msgstr ""
|
||||
|
||||
msgid "Currency"
|
||||
msgstr ""
|
||||
|
||||
msgid "Custom Icon"
|
||||
msgstr ""
|
||||
|
||||
msgid "Cycles"
|
||||
msgstr ""
|
||||
|
||||
msgid "DBus Notification"
|
||||
msgstr ""
|
||||
|
||||
msgid "Details"
|
||||
msgstr ""
|
||||
|
||||
msgid "Detect Bot Owner"
|
||||
msgstr ""
|
||||
|
||||
msgid "Device"
|
||||
msgstr ""
|
||||
|
||||
msgid "Device API Key"
|
||||
msgstr ""
|
||||
|
||||
msgid "Device ID"
|
||||
msgstr ""
|
||||
|
||||
msgid "Device Name"
|
||||
msgstr ""
|
||||
|
||||
msgid "Display Footer"
|
||||
msgstr ""
|
||||
|
||||
msgid "Domain"
|
||||
msgstr ""
|
||||
|
||||
msgid "Duration"
|
||||
msgstr ""
|
||||
|
||||
msgid "Email"
|
||||
msgstr ""
|
||||
|
||||
msgid "Email Header"
|
||||
msgstr ""
|
||||
|
||||
msgid "Encrypted Password"
|
||||
msgstr ""
|
||||
|
||||
msgid "Encrypted Salt"
|
||||
msgstr ""
|
||||
|
||||
msgid "Entity"
|
||||
msgstr ""
|
||||
|
||||
msgid "Event"
|
||||
msgstr ""
|
||||
|
||||
msgid "Events"
|
||||
msgstr ""
|
||||
|
||||
msgid "Expire"
|
||||
msgstr ""
|
||||
|
||||
msgid "Facility"
|
||||
msgstr ""
|
||||
|
||||
msgid "Flair ID"
|
||||
msgstr ""
|
||||
|
||||
msgid "Flair Text"
|
||||
msgstr ""
|
||||
|
||||
msgid "Footer Logo"
|
||||
msgstr ""
|
||||
|
||||
msgid "Forced File Name"
|
||||
msgstr ""
|
||||
|
||||
msgid "Forced Mime Type"
|
||||
msgstr ""
|
||||
|
||||
msgid "From Email"
|
||||
msgstr ""
|
||||
|
||||
msgid "From Name"
|
||||
msgstr ""
|
||||
|
||||
msgid "From Phone No"
|
||||
msgstr ""
|
||||
|
||||
msgid "Gnome Notification"
|
||||
msgstr ""
|
||||
|
||||
msgid "Group"
|
||||
msgstr ""
|
||||
|
||||
msgid "HTTP Header"
|
||||
msgstr ""
|
||||
|
||||
msgid "Hostname"
|
||||
msgstr ""
|
||||
|
||||
msgid "IRC Colors"
|
||||
msgstr ""
|
||||
|
||||
msgid "Icon Type"
|
||||
msgstr ""
|
||||
|
||||
msgid "Identifier"
|
||||
msgstr ""
|
||||
|
||||
msgid "Image Link"
|
||||
msgstr ""
|
||||
|
||||
msgid "Include Footer"
|
||||
msgstr ""
|
||||
|
||||
msgid "Include Image"
|
||||
msgstr ""
|
||||
|
||||
msgid "Include Segment"
|
||||
msgstr ""
|
||||
|
||||
msgid "Is Ad?"
|
||||
msgstr ""
|
||||
|
||||
msgid "Is Spoiler"
|
||||
msgstr ""
|
||||
|
||||
msgid "Kind"
|
||||
msgstr ""
|
||||
|
||||
msgid "Language"
|
||||
msgstr ""
|
||||
|
||||
msgid "Local File"
|
||||
msgstr ""
|
||||
|
||||
msgid "Log PID"
|
||||
msgstr ""
|
||||
|
||||
msgid "Log to STDERR"
|
||||
msgstr ""
|
||||
|
||||
msgid "Long-Lived Access Token"
|
||||
msgstr ""
|
||||
|
||||
msgid "MacOSX Notification"
|
||||
msgstr ""
|
||||
|
||||
msgid "Master Key"
|
||||
msgstr ""
|
||||
|
||||
msgid "Memory"
|
||||
msgstr ""
|
||||
|
||||
msgid "Message Hook"
|
||||
msgstr ""
|
||||
|
||||
msgid "Message Mode"
|
||||
msgstr ""
|
||||
|
||||
msgid "Message Type"
|
||||
msgstr ""
|
||||
|
||||
msgid "Modal"
|
||||
msgstr ""
|
||||
|
||||
msgid "Mode"
|
||||
msgstr ""
|
||||
|
||||
msgid "NSFW"
|
||||
msgstr ""
|
||||
|
||||
msgid "Name"
|
||||
msgstr ""
|
||||
|
||||
msgid "No dependencies."
|
||||
msgstr ""
|
||||
|
||||
msgid "Notification ID"
|
||||
msgstr ""
|
||||
|
||||
msgid "Notify Format"
|
||||
msgstr ""
|
||||
|
||||
msgid "OAuth Access Token"
|
||||
msgstr ""
|
||||
|
||||
msgid "OAuth2 KeyFile"
|
||||
msgstr ""
|
||||
|
||||
msgid ""
|
||||
"Only works with Mac OS X 10.8 and higher. Additionally requires that "
|
||||
"/usr/local/bin/terminal-notifier is locally accessible."
|
||||
msgstr ""
|
||||
|
||||
msgid "Organization"
|
||||
msgstr ""
|
||||
|
||||
msgid "Originating Address"
|
||||
msgstr ""
|
||||
|
||||
msgid "Overflow Mode"
|
||||
msgstr ""
|
||||
|
||||
msgid "Packages are recommended to improve functionality."
|
||||
msgstr ""
|
||||
|
||||
msgid "Packages are required to function."
|
||||
msgstr ""
|
||||
|
||||
msgid "Password"
|
||||
msgstr ""
|
||||
|
||||
msgid "Path"
|
||||
msgstr ""
|
||||
|
||||
msgid "Port"
|
||||
msgstr ""
|
||||
|
||||
msgid "Prefix"
|
||||
msgstr ""
|
||||
|
||||
msgid "Priority"
|
||||
msgstr ""
|
||||
|
||||
msgid "Private Key"
|
||||
msgstr ""
|
||||
|
||||
msgid "Project ID"
|
||||
msgstr ""
|
||||
|
||||
msgid "Provider Key"
|
||||
msgstr ""
|
||||
|
||||
msgid "QOS"
|
||||
msgstr ""
|
||||
|
||||
msgid "Region"
|
||||
msgstr ""
|
||||
|
||||
msgid "Region Name"
|
||||
msgstr ""
|
||||
|
||||
msgid "Remove Tokens"
|
||||
msgstr ""
|
||||
|
||||
msgid "Resubmit Flag"
|
||||
msgstr ""
|
||||
|
||||
msgid "Retry"
|
||||
msgstr ""
|
||||
|
||||
msgid "Rooms"
|
||||
msgstr ""
|
||||
|
||||
msgid "Route"
|
||||
msgstr ""
|
||||
|
||||
msgid "SMTP Server"
|
||||
msgstr ""
|
||||
|
||||
msgid "Schema"
|
||||
msgstr ""
|
||||
|
||||
msgid "Secret Access Key"
|
||||
msgstr ""
|
||||
|
||||
msgid "Secret Key"
|
||||
msgstr ""
|
||||
|
||||
msgid "Secure Mode"
|
||||
msgstr ""
|
||||
|
||||
msgid "Send Replies"
|
||||
msgstr ""
|
||||
|
||||
msgid "Sender ID"
|
||||
msgstr ""
|
||||
|
||||
msgid "Server Key"
|
||||
msgstr ""
|
||||
|
||||
msgid "Server Timeout"
|
||||
msgstr ""
|
||||
|
||||
msgid "Silent Notification"
|
||||
msgstr ""
|
||||
|
||||
msgid "Socket Connect Timeout"
|
||||
msgstr ""
|
||||
|
||||
msgid "Socket Read Timeout"
|
||||
msgstr ""
|
||||
|
||||
msgid "Sound"
|
||||
msgstr ""
|
||||
|
||||
msgid "Sound Link"
|
||||
msgstr ""
|
||||
|
||||
msgid "Source Email"
|
||||
msgstr ""
|
||||
|
||||
msgid "Source JID"
|
||||
msgstr ""
|
||||
|
||||
msgid "Source Phone No"
|
||||
msgstr ""
|
||||
|
||||
msgid "Special Text Color"
|
||||
msgstr ""
|
||||
|
||||
msgid "Sticky"
|
||||
msgstr ""
|
||||
|
||||
msgid "Subtitle"
|
||||
msgstr ""
|
||||
|
||||
msgid "Syslog Mode"
|
||||
msgstr ""
|
||||
|
||||
msgid "Tags"
|
||||
msgstr ""
|
||||
|
||||
msgid "Target Channel"
|
||||
msgstr ""
|
||||
|
||||
msgid "Target Channel ID"
|
||||
msgstr ""
|
||||
|
||||
msgid "Target Chat ID"
|
||||
msgstr ""
|
||||
|
||||
msgid "Target Device"
|
||||
msgstr ""
|
||||
|
||||
msgid "Target Device ID"
|
||||
msgstr ""
|
||||
|
||||
msgid "Target Email"
|
||||
msgstr ""
|
||||
|
||||
msgid "Target Emails"
|
||||
msgstr ""
|
||||
|
||||
msgid "Target Encoded ID"
|
||||
msgstr ""
|
||||
|
||||
msgid "Target Escalation"
|
||||
msgstr ""
|
||||
|
||||
msgid "Target JID"
|
||||
msgstr ""
|
||||
|
||||
msgid "Target Phone No"
|
||||
msgstr ""
|
||||
|
||||
msgid "Target Player ID"
|
||||
msgstr ""
|
||||
|
||||
msgid "Target Queue"
|
||||
msgstr ""
|
||||
|
||||
msgid "Target Room Alias"
|
||||
msgstr ""
|
||||
|
||||
msgid "Target Room ID"
|
||||
msgstr ""
|
||||
|
||||
msgid "Target Schedule"
|
||||
msgstr ""
|
||||
|
||||
msgid "Target Short Code"
|
||||
msgstr ""
|
||||
|
||||
msgid "Target Stream"
|
||||
msgstr ""
|
||||
|
||||
msgid "Target Subreddit"
|
||||
msgstr ""
|
||||
|
||||
msgid "Target Tag ID"
|
||||
msgstr ""
|
||||
|
||||
msgid "Target Team"
|
||||
msgstr ""
|
||||
|
||||
msgid "Target Topic"
|
||||
msgstr ""
|
||||
|
||||
msgid "Target User"
|
||||
msgstr ""
|
||||
|
||||
msgid "Targets"
|
||||
msgstr ""
|
||||
|
||||
msgid "Targets "
|
||||
msgstr ""
|
||||
|
||||
msgid "Team Name"
|
||||
msgstr ""
|
||||
|
||||
msgid "Template"
|
||||
msgstr ""
|
||||
|
||||
msgid "Template Data"
|
||||
msgstr ""
|
||||
|
||||
msgid "Template Path"
|
||||
msgstr ""
|
||||
|
||||
msgid "Template Tokens"
|
||||
msgstr ""
|
||||
|
||||
msgid "Tenant Domain"
|
||||
msgstr ""
|
||||
|
||||
msgid "Text To Speech"
|
||||
msgstr ""
|
||||
|
||||
msgid "To Channel ID"
|
||||
msgstr ""
|
||||
|
||||
msgid "To Email"
|
||||
msgstr ""
|
||||
|
||||
msgid "To User ID"
|
||||
msgstr ""
|
||||
|
||||
msgid "Token"
|
||||
msgstr ""
|
||||
|
||||
msgid "Token A"
|
||||
msgstr ""
|
||||
|
||||
msgid "Token B"
|
||||
msgstr ""
|
||||
|
||||
msgid "Token C"
|
||||
msgstr ""
|
||||
|
||||
msgid "URL"
|
||||
msgstr ""
|
||||
|
||||
msgid "URL Title"
|
||||
msgstr ""
|
||||
|
||||
msgid "Urgency"
|
||||
msgstr ""
|
||||
|
||||
msgid "Use Avatar"
|
||||
msgstr ""
|
||||
|
||||
msgid "Use Blocks"
|
||||
msgstr ""
|
||||
|
||||
msgid "Use Fields"
|
||||
msgstr ""
|
||||
|
||||
msgid "Use Session"
|
||||
msgstr ""
|
||||
|
||||
msgid "User ID"
|
||||
msgstr ""
|
||||
|
||||
msgid "User Key"
|
||||
msgstr ""
|
||||
|
||||
msgid "User Name"
|
||||
msgstr ""
|
||||
|
||||
msgid "Username"
|
||||
msgstr ""
|
||||
|
||||
msgid "Verify SSL"
|
||||
msgstr ""
|
||||
|
||||
msgid "Version"
|
||||
msgstr ""
|
||||
|
||||
msgid "Vibration"
|
||||
msgstr ""
|
||||
|
||||
msgid "Web Based"
|
||||
msgstr ""
|
||||
|
||||
msgid "Web Page Preview"
|
||||
msgstr ""
|
||||
|
||||
msgid "Webhook"
|
||||
msgstr ""
|
||||
|
||||
msgid "Webhook ID"
|
||||
msgstr ""
|
||||
|
||||
msgid "Webhook Key"
|
||||
msgstr ""
|
||||
|
||||
msgid "Webhook Mode"
|
||||
msgstr ""
|
||||
|
||||
msgid "Webhook Token"
|
||||
msgstr ""
|
||||
|
||||
msgid "Workspace"
|
||||
msgstr ""
|
||||
|
||||
msgid "X-Axis"
|
||||
msgstr ""
|
||||
|
||||
msgid "XEP"
|
||||
msgstr ""
|
||||
|
||||
msgid "Y-Axis"
|
||||
msgstr ""
|
||||
|
||||
msgid "libdbus-1.so.x must be installed."
|
||||
msgstr ""
|
||||
|
||||
msgid "ttl"
|
||||
msgstr ""
|
||||
|
BIN
libs/apprise/i18n/en/LC_MESSAGES/apprise.mo
Normal file
BIN
libs/apprise/i18n/en/LC_MESSAGES/apprise.mo
Normal file
Binary file not shown.
|
@ -1,293 +0,0 @@
|
|||
# English translations for apprise.
|
||||
# Copyright (C) 2019 Chris Caron
|
||||
# This file is distributed under the same license as the apprise project.
|
||||
# Chris Caron <lead2gold@gmail.com>, 2019.
|
||||
#
|
||||
msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: apprise 0.7.6\n"
|
||||
"Report-Msgid-Bugs-To: lead2gold@gmail.com\n"
|
||||
"POT-Creation-Date: 2019-05-28 16:56-0400\n"
|
||||
"PO-Revision-Date: 2019-05-24 20:00-0400\n"
|
||||
"Last-Translator: Chris Caron <lead2gold@gmail.com>\n"
|
||||
"Language: en\n"
|
||||
"Language-Team: en <LL@li.org>\n"
|
||||
"Plural-Forms: nplurals=2; plural=(n != 1)\n"
|
||||
"MIME-Version: 1.0\n"
|
||||
"Content-Type: text/plain; charset=utf-8\n"
|
||||
"Content-Transfer-Encoding: 8bit\n"
|
||||
"Generated-By: Babel 2.6.0\n"
|
||||
|
||||
msgid "API Key"
|
||||
msgstr ""
|
||||
|
||||
msgid "Access Key"
|
||||
msgstr ""
|
||||
|
||||
msgid "Access Key ID"
|
||||
msgstr ""
|
||||
|
||||
msgid "Access Secret"
|
||||
msgstr ""
|
||||
|
||||
msgid "Access Token"
|
||||
msgstr ""
|
||||
|
||||
msgid "Account SID"
|
||||
msgstr ""
|
||||
|
||||
msgid "Add Tokens"
|
||||
msgstr ""
|
||||
|
||||
msgid "Application Key"
|
||||
msgstr ""
|
||||
|
||||
msgid "Application Secret"
|
||||
msgstr ""
|
||||
|
||||
msgid "Auth Token"
|
||||
msgstr ""
|
||||
|
||||
msgid "Authorization Token"
|
||||
msgstr ""
|
||||
|
||||
msgid "Avatar Image"
|
||||
msgstr ""
|
||||
|
||||
msgid "Bot Name"
|
||||
msgstr ""
|
||||
|
||||
msgid "Bot Token"
|
||||
msgstr ""
|
||||
|
||||
msgid "Channels"
|
||||
msgstr ""
|
||||
|
||||
msgid "Consumer Key"
|
||||
msgstr ""
|
||||
|
||||
msgid "Consumer Secret"
|
||||
msgstr ""
|
||||
|
||||
msgid "Detect Bot Owner"
|
||||
msgstr ""
|
||||
|
||||
msgid "Device ID"
|
||||
msgstr ""
|
||||
|
||||
msgid "Display Footer"
|
||||
msgstr ""
|
||||
|
||||
msgid "Domain"
|
||||
msgstr ""
|
||||
|
||||
msgid "Duration"
|
||||
msgstr ""
|
||||
|
||||
msgid "Events"
|
||||
msgstr ""
|
||||
|
||||
msgid "Footer Logo"
|
||||
msgstr ""
|
||||
|
||||
msgid "From Email"
|
||||
msgstr ""
|
||||
|
||||
msgid "From Name"
|
||||
msgstr ""
|
||||
|
||||
msgid "From Phone No"
|
||||
msgstr ""
|
||||
|
||||
msgid "Group"
|
||||
msgstr ""
|
||||
|
||||
msgid "HTTP Header"
|
||||
msgstr ""
|
||||
|
||||
msgid "Hostname"
|
||||
msgstr ""
|
||||
|
||||
msgid "Include Image"
|
||||
msgstr ""
|
||||
|
||||
msgid "Modal"
|
||||
msgstr ""
|
||||
|
||||
msgid "Notify Format"
|
||||
msgstr ""
|
||||
|
||||
msgid "Organization"
|
||||
msgstr ""
|
||||
|
||||
msgid "Overflow Mode"
|
||||
msgstr ""
|
||||
|
||||
msgid "Password"
|
||||
msgstr ""
|
||||
|
||||
msgid "Port"
|
||||
msgstr ""
|
||||
|
||||
msgid "Priority"
|
||||
msgstr ""
|
||||
|
||||
msgid "Provider Key"
|
||||
msgstr ""
|
||||
|
||||
msgid "Region"
|
||||
msgstr ""
|
||||
|
||||
msgid "Region Name"
|
||||
msgstr ""
|
||||
|
||||
msgid "Remove Tokens"
|
||||
msgstr ""
|
||||
|
||||
msgid "Rooms"
|
||||
msgstr ""
|
||||
|
||||
msgid "SMTP Server"
|
||||
msgstr ""
|
||||
|
||||
msgid "Schema"
|
||||
msgstr ""
|
||||
|
||||
msgid "Secret Access Key"
|
||||
msgstr ""
|
||||
|
||||
msgid "Secret Key"
|
||||
msgstr ""
|
||||
|
||||
msgid "Secure Mode"
|
||||
msgstr ""
|
||||
|
||||
msgid "Server Timeout"
|
||||
msgstr ""
|
||||
|
||||
msgid "Sound"
|
||||
msgstr ""
|
||||
|
||||
msgid "Source JID"
|
||||
msgstr ""
|
||||
|
||||
msgid "Target Channel"
|
||||
msgstr ""
|
||||
|
||||
msgid "Target Chat ID"
|
||||
msgstr ""
|
||||
|
||||
msgid "Target Device"
|
||||
msgstr ""
|
||||
|
||||
msgid "Target Device ID"
|
||||
msgstr ""
|
||||
|
||||
msgid "Target Email"
|
||||
msgstr ""
|
||||
|
||||
msgid "Target Emails"
|
||||
msgstr ""
|
||||
|
||||
msgid "Target Encoded ID"
|
||||
msgstr ""
|
||||
|
||||
msgid "Target JID"
|
||||
msgstr ""
|
||||
|
||||
msgid "Target Phone No"
|
||||
msgstr ""
|
||||
|
||||
msgid "Target Room Alias"
|
||||
msgstr ""
|
||||
|
||||
msgid "Target Room ID"
|
||||
msgstr ""
|
||||
|
||||
msgid "Target Short Code"
|
||||
msgstr ""
|
||||
|
||||
msgid "Target Tag ID"
|
||||
msgstr ""
|
||||
|
||||
msgid "Target Topic"
|
||||
msgstr ""
|
||||
|
||||
msgid "Target User"
|
||||
msgstr ""
|
||||
|
||||
msgid "Targets"
|
||||
msgstr ""
|
||||
|
||||
msgid "Text To Speech"
|
||||
msgstr ""
|
||||
|
||||
msgid "To Channel ID"
|
||||
msgstr ""
|
||||
|
||||
msgid "To Email"
|
||||
msgstr ""
|
||||
|
||||
msgid "To User ID"
|
||||
msgstr ""
|
||||
|
||||
msgid "Token"
|
||||
msgstr ""
|
||||
|
||||
msgid "Token A"
|
||||
msgstr ""
|
||||
|
||||
msgid "Token B"
|
||||
msgstr ""
|
||||
|
||||
msgid "Token C"
|
||||
msgstr ""
|
||||
|
||||
msgid "Urgency"
|
||||
msgstr ""
|
||||
|
||||
msgid "Use Avatar"
|
||||
msgstr ""
|
||||
|
||||
msgid "User"
|
||||
msgstr ""
|
||||
|
||||
msgid "User Key"
|
||||
msgstr ""
|
||||
|
||||
msgid "User Name"
|
||||
msgstr ""
|
||||
|
||||
msgid "Username"
|
||||
msgstr ""
|
||||
|
||||
msgid "Verify SSL"
|
||||
msgstr ""
|
||||
|
||||
msgid "Version"
|
||||
msgstr ""
|
||||
|
||||
msgid "Webhook"
|
||||
msgstr ""
|
||||
|
||||
msgid "Webhook ID"
|
||||
msgstr ""
|
||||
|
||||
msgid "Webhook Mode"
|
||||
msgstr ""
|
||||
|
||||
msgid "Webhook Token"
|
||||
msgstr ""
|
||||
|
||||
msgid "X-Axis"
|
||||
msgstr ""
|
||||
|
||||
msgid "XEP"
|
||||
msgstr ""
|
||||
|
||||
msgid "Y-Axis"
|
||||
msgstr ""
|
||||
|
||||
#~ msgid "Access Key Secret"
|
||||
#~ msgstr ""
|
||||
|
|
@ -265,7 +265,7 @@ class NotifyBase(BASE_OBJECT):
|
|||
)
|
||||
|
||||
def notify(self, body, title=None, notify_type=NotifyType.INFO,
|
||||
overflow=None, attach=None, **kwargs):
|
||||
overflow=None, attach=None, body_format=None, **kwargs):
|
||||
"""
|
||||
Performs notification
|
||||
|
||||
|
@ -291,18 +291,22 @@ class NotifyBase(BASE_OBJECT):
|
|||
title = '' if not title else title
|
||||
|
||||
# Apply our overflow (if defined)
|
||||
for chunk in self._apply_overflow(body=body, title=title,
|
||||
overflow=overflow):
|
||||
for chunk in self._apply_overflow(
|
||||
body=body, title=title, overflow=overflow,
|
||||
body_format=body_format):
|
||||
|
||||
# Send notification
|
||||
if not self.send(body=chunk['body'], title=chunk['title'],
|
||||
notify_type=notify_type, attach=attach):
|
||||
notify_type=notify_type, attach=attach,
|
||||
body_format=body_format):
|
||||
|
||||
# Toggle our return status flag
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
def _apply_overflow(self, body, title=None, overflow=None):
|
||||
def _apply_overflow(self, body, title=None, overflow=None,
|
||||
body_format=None):
|
||||
"""
|
||||
Takes the message body and title as input. This function then
|
||||
applies any defined overflow restrictions associated with the
|
||||
|
@ -334,18 +338,24 @@ class NotifyBase(BASE_OBJECT):
|
|||
overflow = self.overflow_mode
|
||||
|
||||
if self.title_maxlen <= 0 and len(title) > 0:
|
||||
if self.notify_format == NotifyFormat.MARKDOWN:
|
||||
# Content is appended to body as markdown
|
||||
body = '**{}**\r\n{}'.format(title, body)
|
||||
|
||||
elif self.notify_format == NotifyFormat.HTML:
|
||||
if self.notify_format == NotifyFormat.HTML:
|
||||
# Content is appended to body as html
|
||||
body = '<{open_tag}>{title}</{close_tag}>' \
|
||||
'<br />\r\n{body}'.format(
|
||||
open_tag=self.default_html_tag_id,
|
||||
title=self.escape_html(title),
|
||||
title=title,
|
||||
close_tag=self.default_html_tag_id,
|
||||
body=body)
|
||||
|
||||
elif self.notify_format == NotifyFormat.MARKDOWN and \
|
||||
body_format == NotifyFormat.TEXT:
|
||||
# Content is appended to body as markdown
|
||||
title = title.lstrip('\r\n \t\v\f#-')
|
||||
if title:
|
||||
# Content is appended to body as text
|
||||
body = '# {}\r\n{}'.format(title, body)
|
||||
|
||||
else:
|
||||
# Content is appended to body as text
|
||||
body = '{}\r\n{}'.format(title, body)
|
||||
|
|
396
libs/apprise/plugins/NotifyDapnet.py
Normal file
396
libs/apprise/plugins/NotifyDapnet.py
Normal file
|
@ -0,0 +1,396 @@
|
|||
# -*- 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.
|
||||
|
||||
# To use this plugin, sign up with Hampager (you need to be a licensed
|
||||
# ham radio operator
|
||||
# http://www.hampager.de/
|
||||
#
|
||||
# You're done at this point, you only need to know your user/pass that
|
||||
# you signed up with.
|
||||
|
||||
# The following URLs would be accepted by Apprise:
|
||||
# - dapnet://{user}:{password}@{callsign}
|
||||
# - dapnet://{user}:{password}@{callsign1}/{callsign2}
|
||||
|
||||
# Optional parameters:
|
||||
# - priority (NORMAL or EMERGENCY). Default: NORMAL
|
||||
# - txgroups --> comma-separated list of DAPNET transmitter
|
||||
# groups. Default: 'dl-all'
|
||||
# https://hampager.de/#/transmitters/groups
|
||||
|
||||
from json import dumps
|
||||
|
||||
# The API reference used to build this plugin was documented here:
|
||||
# https://hampager.de/dokuwiki/doku.php#dapnet_api
|
||||
#
|
||||
import requests
|
||||
from requests.auth import HTTPBasicAuth
|
||||
|
||||
from .NotifyBase import NotifyBase
|
||||
from ..AppriseLocale import gettext_lazy as _
|
||||
from ..URLBase import PrivacyMode
|
||||
from ..common import NotifyType
|
||||
from ..utils import is_call_sign
|
||||
from ..utils import parse_call_sign
|
||||
from ..utils import parse_list
|
||||
from ..utils import parse_bool
|
||||
|
||||
|
||||
class DapnetPriority(object):
|
||||
NORMAL = 0
|
||||
EMERGENCY = 1
|
||||
|
||||
|
||||
DAPNET_PRIORITIES = (
|
||||
DapnetPriority.NORMAL,
|
||||
DapnetPriority.EMERGENCY,
|
||||
)
|
||||
|
||||
|
||||
class NotifyDapnet(NotifyBase):
|
||||
"""
|
||||
A wrapper for DAPNET / Hampager Notifications
|
||||
"""
|
||||
|
||||
# The default descriptive name associated with the Notification
|
||||
service_name = 'Dapnet'
|
||||
|
||||
# The services URL
|
||||
service_url = 'https://hampager.de/'
|
||||
|
||||
# The default secure protocol
|
||||
secure_protocol = 'dapnet'
|
||||
|
||||
# A URL that takes you to the setup/help of the specific protocol
|
||||
setup_url = 'https://github.com/caronc/apprise/wiki/Notify_dapnet'
|
||||
|
||||
# Dapnet uses the http protocol with JSON requests
|
||||
notify_url = 'http://www.hampager.de:8080/calls'
|
||||
|
||||
# The maximum length of the body
|
||||
body_maxlen = 80
|
||||
|
||||
# A title can not be used for Dapnet Messages. Setting this to zero will
|
||||
# cause any title (if defined) to get placed into the message body.
|
||||
title_maxlen = 0
|
||||
|
||||
# The maximum amount of emails that can reside within a single transmission
|
||||
default_batch_size = 50
|
||||
|
||||
# Define object templates
|
||||
templates = ('{schema}://{user}:{password}@{targets}',)
|
||||
|
||||
# Define our template tokens
|
||||
template_tokens = dict(
|
||||
NotifyBase.template_tokens,
|
||||
**{
|
||||
'user': {
|
||||
'name': _('User Name'),
|
||||
'type': 'string',
|
||||
'required': True,
|
||||
},
|
||||
'password': {
|
||||
'name': _('Password'),
|
||||
'type': 'string',
|
||||
'private': True,
|
||||
'required': True,
|
||||
},
|
||||
'target_callsign': {
|
||||
'name': _('Target Callsign'),
|
||||
'type': 'string',
|
||||
'regex': (
|
||||
r'^[a-z0-9]{2,5}(-[a-z0-9]{1,2})?$', 'i',
|
||||
),
|
||||
'map_to': 'targets',
|
||||
},
|
||||
'targets': {
|
||||
'name': _('Targets'),
|
||||
'type': 'list:string',
|
||||
'required': True,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
# Define our template arguments
|
||||
template_args = dict(
|
||||
NotifyBase.template_args,
|
||||
**{
|
||||
'to': {
|
||||
'name': _('Target Callsign'),
|
||||
'type': 'string',
|
||||
'map_to': 'targets',
|
||||
},
|
||||
'priority': {
|
||||
'name': _('Priority'),
|
||||
'type': 'choice:int',
|
||||
'values': DAPNET_PRIORITIES,
|
||||
'default': DapnetPriority.NORMAL,
|
||||
},
|
||||
'txgroups': {
|
||||
'name': _('Transmitter Groups'),
|
||||
'type': 'string',
|
||||
'default': 'dl-all',
|
||||
'private': True,
|
||||
},
|
||||
'batch': {
|
||||
'name': _('Batch Mode'),
|
||||
'type': 'bool',
|
||||
'default': False,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
def __init__(self, targets=None, priority=None, txgroups=None,
|
||||
batch=False, **kwargs):
|
||||
"""
|
||||
Initialize Dapnet Object
|
||||
"""
|
||||
super(NotifyDapnet, self).__init__(**kwargs)
|
||||
|
||||
# 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
|
||||
|
||||
if not (self.user and self.password):
|
||||
msg = 'A Dapnet user/pass was not provided.'
|
||||
self.logger.warning(msg)
|
||||
raise TypeError(msg)
|
||||
|
||||
# Get the transmitter group
|
||||
self.txgroups = parse_list(
|
||||
NotifyDapnet.template_args['txgroups']['default']
|
||||
if not txgroups else txgroups)
|
||||
|
||||
# Prepare Batch Mode Flag
|
||||
self.batch = batch
|
||||
|
||||
for target in parse_call_sign(targets):
|
||||
# Validate targets and drop bad ones:
|
||||
result = is_call_sign(target)
|
||||
if not result:
|
||||
self.logger.warning(
|
||||
'Dropping invalid Amateur radio call sign ({}).'.format(
|
||||
target),
|
||||
)
|
||||
continue
|
||||
|
||||
# Store callsign without SSID and
|
||||
# ignore duplicates
|
||||
if result['callsign'] not in self.targets:
|
||||
self.targets.append(result['callsign'])
|
||||
|
||||
return
|
||||
|
||||
def send(self, body, title='', notify_type=NotifyType.INFO, **kwargs):
|
||||
"""
|
||||
Perform Dapnet Notification
|
||||
"""
|
||||
|
||||
if not self.targets:
|
||||
# There is no one to email; we're done
|
||||
self.logger.warning(
|
||||
'There are no Amateur radio callsigns to notify')
|
||||
return False
|
||||
|
||||
# Send in batches if identified to do so
|
||||
batch_size = 1 if not self.batch else self.default_batch_size
|
||||
|
||||
headers = {
|
||||
'User-Agent': self.app_id,
|
||||
'Content-Type': 'application/json; charset=utf-8',
|
||||
}
|
||||
|
||||
# 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)
|
||||
|
||||
for index in range(0, len(targets), batch_size):
|
||||
|
||||
# prepare JSON payload
|
||||
payload = {
|
||||
'text': body,
|
||||
'callSignNames': targets[index:index + batch_size],
|
||||
'transmitterGroupNames': self.txgroups,
|
||||
'emergency': emergency_mode,
|
||||
}
|
||||
|
||||
self.logger.debug('DAPNET POST URL: %s' % self.notify_url)
|
||||
self.logger.debug('DAPNET Payload: %s' % dumps(payload))
|
||||
|
||||
# Always call throttle before any remote server i/o is made
|
||||
self.throttle()
|
||||
try:
|
||||
r = requests.post(
|
||||
self.notify_url,
|
||||
data=dumps(payload),
|
||||
headers=headers,
|
||||
auth=HTTPBasicAuth(
|
||||
username=self.user, password=self.password),
|
||||
verify=self.verify_certificate,
|
||||
timeout=self.request_timeout,
|
||||
)
|
||||
if r.status_code != requests.codes.created:
|
||||
# We had a problem
|
||||
|
||||
self.logger.warning(
|
||||
'Failed to send DAPNET notification {} to {}: '
|
||||
'error={}.'.format(
|
||||
payload['text'],
|
||||
' to {}'.format(self.targets),
|
||||
r.status_code
|
||||
)
|
||||
)
|
||||
|
||||
self.logger.debug(
|
||||
'Response Details:\r\n{}'.format(r.content))
|
||||
|
||||
# Mark our failure
|
||||
has_error = True
|
||||
|
||||
else:
|
||||
self.logger.info(
|
||||
'Sent \'{}\' DAPNET notification {}'.format(
|
||||
payload['text'], 'to {}'.format(self.targets)
|
||||
)
|
||||
)
|
||||
|
||||
except requests.RequestException as e:
|
||||
self.logger.warning(
|
||||
'A Connection error occurred sending DAPNET '
|
||||
'notification to {}'.format(self.targets)
|
||||
)
|
||||
self.logger.debug('Socket Exception: %s' % str(e))
|
||||
|
||||
# Mark our failure
|
||||
has_error = True
|
||||
|
||||
return not has_error
|
||||
|
||||
def url(self, privacy=False, *args, **kwargs):
|
||||
"""
|
||||
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],
|
||||
'batch': 'yes' if self.batch else 'no',
|
||||
'txgroups': ','.join(self.txgroups),
|
||||
}
|
||||
|
||||
# Extend our parameters
|
||||
params.update(self.url_parameters(privacy=privacy, *args, **kwargs))
|
||||
|
||||
# Setup Authentication
|
||||
auth = '{user}:{password}@'.format(
|
||||
user=NotifyDapnet.quote(self.user, safe=""),
|
||||
password=self.pprint(
|
||||
self.password, privacy, mode=PrivacyMode.Secret, safe=''
|
||||
),
|
||||
)
|
||||
|
||||
return '{schema}://{auth}{targets}?{params}'.format(
|
||||
schema=self.secure_protocol,
|
||||
auth=auth,
|
||||
targets='/'.join([self.pprint(x, privacy, safe='')
|
||||
for x in self.targets]),
|
||||
params=NotifyDapnet.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
|
||||
|
||||
# All elements are targets
|
||||
results['targets'] = [NotifyDapnet.unquote(results['host'])]
|
||||
|
||||
# All entries after the hostname are additional targets
|
||||
results['targets'].extend(NotifyDapnet.split_path(results['fullpath']))
|
||||
|
||||
# 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'] += \
|
||||
NotifyDapnet.parse_list(results['qsd']['to'])
|
||||
|
||||
# Check for 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
|
||||
|
||||
# Check for one or multiple transmitter groups (comma separated)
|
||||
# and split them up, when necessary
|
||||
if 'txgroups' in results['qsd']:
|
||||
results['txgroups'] = \
|
||||
[x.lower() for x in
|
||||
NotifyDapnet.parse_list(results['qsd']['txgroups'])]
|
||||
|
||||
# Get Batch Mode Flag
|
||||
results['batch'] = \
|
||||
parse_bool(results['qsd'].get(
|
||||
'batch', NotifyDapnet.template_args['batch']['default']))
|
||||
|
||||
return results
|
|
@ -129,7 +129,7 @@ EMAIL_TEMPLATES = (
|
|||
r'(?P<domain>(hotmail|live)\.com)$', re.I),
|
||||
{
|
||||
'port': 587,
|
||||
'smtp_host': 'smtp.live.com',
|
||||
'smtp_host': 'smtp-mail.outlook.com',
|
||||
'secure': True,
|
||||
'secure_mode': SecureMailMode.STARTTLS,
|
||||
'login_type': (WebBaseLogin.EMAIL, )
|
||||
|
@ -235,7 +235,6 @@ EMAIL_TEMPLATES = (
|
|||
},
|
||||
),
|
||||
|
||||
|
||||
# SendGrid (Email Server)
|
||||
# You must specify an authenticated sender address in the from= settings
|
||||
# and a valid email in the to= to deliver your emails to
|
||||
|
@ -253,6 +252,36 @@ EMAIL_TEMPLATES = (
|
|||
},
|
||||
),
|
||||
|
||||
# 163.com
|
||||
(
|
||||
'163.com',
|
||||
re.compile(
|
||||
r'^((?P<label>[^+]+)\+)?(?P<id>[^@]+)@'
|
||||
r'(?P<domain>163\.com)$', re.I),
|
||||
{
|
||||
'port': 465,
|
||||
'smtp_host': 'smtp.163.com',
|
||||
'secure': True,
|
||||
'secure_mode': SecureMailMode.SSL,
|
||||
'login_type': (WebBaseLogin.EMAIL, )
|
||||
},
|
||||
),
|
||||
|
||||
# Foxmail.com
|
||||
(
|
||||
'Foxmail.com',
|
||||
re.compile(
|
||||
r'^((?P<label>[^+]+)\+)?(?P<id>[^@]+)@'
|
||||
r'(?P<domain>(foxmail|qq)\.com)$', re.I),
|
||||
{
|
||||
'port': 587,
|
||||
'smtp_host': 'smtp.qq.com',
|
||||
'secure': True,
|
||||
'secure_mode': SecureMailMode.STARTTLS,
|
||||
'login_type': (WebBaseLogin.EMAIL, )
|
||||
},
|
||||
),
|
||||
|
||||
# Catch All
|
||||
(
|
||||
'Custom',
|
||||
|
@ -708,8 +737,8 @@ class NotifyEmail(NotifyBase):
|
|||
attachment.url(privacy=True)))
|
||||
|
||||
with open(attachment.path, "rb") as abody:
|
||||
app = MIMEApplication(
|
||||
abody.read(), attachment.mimetype)
|
||||
app = MIMEApplication(abody.read())
|
||||
app.set_type(attachment.mimetype)
|
||||
|
||||
app.add_header(
|
||||
'Content-Disposition',
|
||||
|
|
|
@ -52,8 +52,13 @@ from ..NotifyBase import NotifyBase
|
|||
from ...common import NotifyType
|
||||
from ...utils import validate_regex
|
||||
from ...utils import parse_list
|
||||
from ...utils import parse_bool
|
||||
from ...common import NotifyImageSize
|
||||
from ...AppriseAttachment import AppriseAttachment
|
||||
from ...AppriseLocale import gettext_lazy as _
|
||||
from .common import (FCMMode, FCM_MODES)
|
||||
from .priority import (FCM_PRIORITIES, FCMPriorityManager)
|
||||
from .color import FCMColorManager
|
||||
|
||||
# Default our global support flag
|
||||
NOTIFY_FCM_SUPPORT_ENABLED = False
|
||||
|
@ -80,26 +85,6 @@ FCM_HTTP_ERROR_MAP = {
|
|||
}
|
||||
|
||||
|
||||
class FCMMode(object):
|
||||
"""
|
||||
Define the Firebase Cloud Messaging Modes
|
||||
"""
|
||||
# The legacy way of sending a message
|
||||
Legacy = "legacy"
|
||||
|
||||
# The new API
|
||||
OAuth2 = "oauth2"
|
||||
|
||||
|
||||
# FCM Modes
|
||||
FCM_MODES = (
|
||||
# Legacy API
|
||||
FCMMode.Legacy,
|
||||
# HTTP v1 URL
|
||||
FCMMode.OAuth2,
|
||||
)
|
||||
|
||||
|
||||
class NotifyFCM(NotifyBase):
|
||||
"""
|
||||
A wrapper for Google's Firebase Cloud Messaging Notifications
|
||||
|
@ -136,13 +121,12 @@ class NotifyFCM(NotifyBase):
|
|||
# If it is more than this, then it is not accepted.
|
||||
max_fcm_keyfile_size = 5000
|
||||
|
||||
# Allows the user to specify the NotifyImageSize object
|
||||
image_size = NotifyImageSize.XY_256
|
||||
|
||||
# The maximum length of the body
|
||||
body_maxlen = 1024
|
||||
|
||||
# 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 = (
|
||||
# OAuth2
|
||||
|
@ -163,12 +147,6 @@ class NotifyFCM(NotifyBase):
|
|||
'type': 'string',
|
||||
'private': True,
|
||||
},
|
||||
'mode': {
|
||||
'name': _('Mode'),
|
||||
'type': 'choice:string',
|
||||
'values': FCM_MODES,
|
||||
'default': FCMMode.Legacy,
|
||||
},
|
||||
'project': {
|
||||
'name': _('Project ID'),
|
||||
'type': 'string',
|
||||
|
@ -195,10 +173,47 @@ class NotifyFCM(NotifyBase):
|
|||
'to': {
|
||||
'alias_of': 'targets',
|
||||
},
|
||||
'mode': {
|
||||
'name': _('Mode'),
|
||||
'type': 'choice:string',
|
||||
'values': FCM_MODES,
|
||||
'default': FCMMode.Legacy,
|
||||
},
|
||||
'priority': {
|
||||
'name': _('Mode'),
|
||||
'type': 'choice:string',
|
||||
'values': FCM_PRIORITIES,
|
||||
},
|
||||
'image_url': {
|
||||
'name': _('Custom Image URL'),
|
||||
'type': 'string',
|
||||
},
|
||||
'image': {
|
||||
'name': _('Include Image'),
|
||||
'type': 'bool',
|
||||
'default': False,
|
||||
'map_to': 'include_image',
|
||||
},
|
||||
# Color can either be yes, no, or a #rrggbb (
|
||||
# rrggbb without hashtag is accepted to)
|
||||
'color': {
|
||||
'name': _('Notification Color'),
|
||||
'type': 'string',
|
||||
'default': 'yes',
|
||||
},
|
||||
})
|
||||
|
||||
# Define our data entry
|
||||
template_kwargs = {
|
||||
'data_kwargs': {
|
||||
'name': _('Data Entries'),
|
||||
'prefix': '+',
|
||||
},
|
||||
}
|
||||
|
||||
def __init__(self, project, apikey, targets=None, mode=None, keyfile=None,
|
||||
**kwargs):
|
||||
data_kwargs=None, image_url=None, include_image=False,
|
||||
color=None, priority=None, **kwargs):
|
||||
"""
|
||||
Initialize Firebase Cloud Messaging
|
||||
|
||||
|
@ -214,7 +229,7 @@ class NotifyFCM(NotifyBase):
|
|||
self.mode = NotifyFCM.template_tokens['mode']['default'] \
|
||||
if not isinstance(mode, six.string_types) else mode.lower()
|
||||
if self.mode and self.mode not in FCM_MODES:
|
||||
msg = 'The mode specified ({}) is invalid.'.format(mode)
|
||||
msg = 'The FCM mode specified ({}) is invalid.'.format(mode)
|
||||
self.logger.warning(msg)
|
||||
raise TypeError(msg)
|
||||
|
||||
|
@ -267,6 +282,29 @@ class NotifyFCM(NotifyBase):
|
|||
|
||||
# Acquire Device IDs to notify
|
||||
self.targets = parse_list(targets)
|
||||
|
||||
# Our data Keyword/Arguments to include in our outbound payload
|
||||
self.data_kwargs = {}
|
||||
if isinstance(data_kwargs, dict):
|
||||
self.data_kwargs.update(data_kwargs)
|
||||
|
||||
# Include the image as part of the payload
|
||||
self.include_image = include_image
|
||||
|
||||
# A Custom Image URL
|
||||
# FCM allows you to provide a remote https?:// URL to an image_url
|
||||
# located on the internet that it will download and include in the
|
||||
# payload.
|
||||
#
|
||||
# self.image_url() is reserved as an internal function name; so we
|
||||
# jsut store it into a different variable for now
|
||||
self.image_src = image_url
|
||||
|
||||
# Initialize our priority
|
||||
self.priority = FCMPriorityManager(self.mode, priority)
|
||||
|
||||
# Initialize our color
|
||||
self.color = FCMColorManager(color, asset=self.asset)
|
||||
return
|
||||
|
||||
@property
|
||||
|
@ -335,6 +373,10 @@ class NotifyFCM(NotifyBase):
|
|||
# Prepare our notify URL
|
||||
notify_url = self.notify_legacy_url
|
||||
|
||||
# Acquire image url
|
||||
image = self.image_url(notify_type) \
|
||||
if not self.image_src else self.image_src
|
||||
|
||||
has_error = False
|
||||
# Create a copy of the targets list
|
||||
targets = list(self.targets)
|
||||
|
@ -352,6 +394,17 @@ class NotifyFCM(NotifyBase):
|
|||
}
|
||||
}
|
||||
|
||||
if self.color:
|
||||
# Acquire our color
|
||||
payload['message']['android'] = {
|
||||
'notification': {'color': self.color.get(notify_type)}}
|
||||
|
||||
if self.include_image and image:
|
||||
payload['message']['notification']['image'] = image
|
||||
|
||||
if self.data_kwargs:
|
||||
payload['message']['data'] = self.data_kwargs
|
||||
|
||||
if recipient[0] == '#':
|
||||
payload['message']['topic'] = recipient[1:]
|
||||
self.logger.debug(
|
||||
|
@ -373,6 +426,18 @@ class NotifyFCM(NotifyBase):
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
if self.color:
|
||||
# Acquire our color
|
||||
payload['notification']['notification']['color'] = \
|
||||
self.color.get(notify_type)
|
||||
|
||||
if self.include_image and image:
|
||||
payload['notification']['notification']['image'] = image
|
||||
|
||||
if self.data_kwargs:
|
||||
payload['data'] = self.data_kwargs
|
||||
|
||||
if recipient[0] == '#':
|
||||
payload['to'] = '/topics/{}'.format(recipient)
|
||||
self.logger.debug(
|
||||
|
@ -385,6 +450,18 @@ 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())
|
||||
|
||||
self.logger.debug(
|
||||
'FCM %s POST URL: %s (cert_verify=%r)',
|
||||
self.mode, notify_url, self.verify_certificate,
|
||||
|
@ -443,16 +520,30 @@ class NotifyFCM(NotifyBase):
|
|||
# Define any URL parameters
|
||||
params = {
|
||||
'mode': self.mode,
|
||||
'image': 'yes' if self.include_image else 'no',
|
||||
'color': str(self.color),
|
||||
}
|
||||
|
||||
if self.priority:
|
||||
# Store our priority if one was defined
|
||||
params['priority'] = str(self.priority)
|
||||
|
||||
if self.keyfile:
|
||||
# Include our keyfile if specified
|
||||
params['keyfile'] = NotifyFCM.quote(
|
||||
self.keyfile[0].url(privacy=privacy), safe='')
|
||||
|
||||
if self.image_src:
|
||||
# Include our image path as part of our URL payload
|
||||
params['image_url'] = self.image_src
|
||||
|
||||
# Extend our parameters
|
||||
params.update(self.url_parameters(privacy=privacy, *args, **kwargs))
|
||||
|
||||
# Add our data keyword/args into our URL response
|
||||
params.update(
|
||||
{'+{}'.format(k): v for k, v in self.data_kwargs.items()})
|
||||
|
||||
reference = NotifyFCM.quote(self.project) \
|
||||
if self.mode == FCMMode.OAuth2 \
|
||||
else self.pprint(self.apikey, privacy, safe='')
|
||||
|
@ -507,4 +598,30 @@ class NotifyFCM(NotifyBase):
|
|||
results['keyfile'] = \
|
||||
NotifyFCM.unquote(results['qsd']['keyfile'])
|
||||
|
||||
# Our Priority
|
||||
if 'priority' in results['qsd'] and results['qsd']['priority']:
|
||||
results['priority'] = \
|
||||
NotifyFCM.unquote(results['qsd']['priority'])
|
||||
|
||||
# Our Color
|
||||
if 'color' in results['qsd'] and results['qsd']['color']:
|
||||
results['color'] = \
|
||||
NotifyFCM.unquote(results['qsd']['color'])
|
||||
|
||||
# Boolean to include an image or not
|
||||
results['include_image'] = parse_bool(results['qsd'].get(
|
||||
'image', NotifyFCM.template_args['image']['default']))
|
||||
|
||||
# Extract image_url if it was specified
|
||||
if 'image_url' in results['qsd']:
|
||||
results['image_url'] = \
|
||||
NotifyFCM.unquote(results['qsd']['image_url'])
|
||||
if 'image' not in results['qsd']:
|
||||
# Toggle default behaviour if a custom image was provided
|
||||
# but ONLY if the `image` boolean was not set
|
||||
results['include_image'] = True
|
||||
|
||||
# Store our data keyword/args if specified
|
||||
results['data_kwargs'] = results['qsd+']
|
||||
|
||||
return results
|
||||
|
|
127
libs/apprise/plugins/NotifyFCM/color.py
Normal file
127
libs/apprise/plugins/NotifyFCM/color.py
Normal file
|
@ -0,0 +1,127 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
#
|
||||
# Copyright (C) 2021 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.
|
||||
|
||||
# New priorities are defined here:
|
||||
# - https://firebase.google.com/docs/reference/fcm/rest/v1/\
|
||||
# projects.messages#NotificationPriority
|
||||
|
||||
# Legacy color payload example here:
|
||||
# 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):
|
||||
"""
|
||||
A Simple object to accept either a boolean value
|
||||
- True: Use colors provided by Apprise
|
||||
- False: Do not use colors at all
|
||||
- rrggbb: where you provide the rgb values (hence #333333)
|
||||
- rgb: is also accepted as rgb values (hence #333)
|
||||
|
||||
For RGB colors, the hashtag is optional
|
||||
"""
|
||||
|
||||
__color_rgb = re.compile(
|
||||
r'#?((?P<r1>[0-9A-F]{2})(?P<g1>[0-9A-F]{2})(?P<b1>[0-9A-F]{2})'
|
||||
r'|(?P<r2>[0-9A-F])(?P<g2>[0-9A-F])(?P<b2>[0-9A-F]))', re.IGNORECASE)
|
||||
|
||||
def __init__(self, color, asset=None):
|
||||
"""
|
||||
Parses the color object accordingly
|
||||
"""
|
||||
|
||||
# Initialize an asset object if one isn't otherwise defined
|
||||
self.asset = asset \
|
||||
if isinstance(asset, AppriseAsset) else AppriseAsset()
|
||||
|
||||
# Prepare our color
|
||||
self.color = color
|
||||
if isinstance(color, six.string_types):
|
||||
self.color = self.__color_rgb.match(color)
|
||||
if self.color:
|
||||
# Store our RGB value as #rrggbb
|
||||
self.color = '{red}{green}{blue}'.format(
|
||||
red=self.color.group('r1'),
|
||||
green=self.color.group('g1'),
|
||||
blue=self.color.group('b1')).lower() \
|
||||
if self.color.group('r1') else \
|
||||
'{red1}{red2}{green1}{green2}{blue1}{blue2}'.format(
|
||||
red1=self.color.group('r2'),
|
||||
red2=self.color.group('r2'),
|
||||
green1=self.color.group('g2'),
|
||||
green2=self.color.group('g2'),
|
||||
blue1=self.color.group('b2'),
|
||||
blue2=self.color.group('b2')).lower()
|
||||
|
||||
if self.color is None:
|
||||
# Color not determined, so base it on boolean parser
|
||||
self.color = parse_bool(color)
|
||||
|
||||
def get(self, notify_type=NotifyType.INFO):
|
||||
"""
|
||||
Returns color or true/false value based on configuration
|
||||
"""
|
||||
|
||||
if isinstance(self.color, bool) and self.color:
|
||||
# We want to use the asset value
|
||||
return self.asset.color(notify_type=notify_type)
|
||||
|
||||
elif self.color:
|
||||
# return our color as is
|
||||
return '#' + self.color
|
||||
|
||||
# No color to return
|
||||
return None
|
||||
|
||||
def __str__(self):
|
||||
"""
|
||||
our color representation
|
||||
"""
|
||||
if isinstance(self.color, bool):
|
||||
return 'yes' if self.color else 'no'
|
||||
|
||||
# otherwise return our color
|
||||
return self.color
|
||||
|
||||
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
|
||||
"""
|
||||
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
|
42
libs/apprise/plugins/NotifyFCM/common.py
Normal file
42
libs/apprise/plugins/NotifyFCM/common.py
Normal file
|
@ -0,0 +1,42 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
#
|
||||
# Copyright (C) 2021 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.
|
||||
class FCMMode(object):
|
||||
"""
|
||||
Define the Firebase Cloud Messaging Modes
|
||||
"""
|
||||
# The legacy way of sending a message
|
||||
Legacy = "legacy"
|
||||
|
||||
# The new API
|
||||
OAuth2 = "oauth2"
|
||||
|
||||
|
||||
# FCM Modes
|
||||
FCM_MODES = (
|
||||
# Legacy API
|
||||
FCMMode.Legacy,
|
||||
# HTTP v1 URL
|
||||
FCMMode.OAuth2,
|
||||
)
|
255
libs/apprise/plugins/NotifyFCM/priority.py
Normal file
255
libs/apprise/plugins/NotifyFCM/priority.py
Normal file
|
@ -0,0 +1,255 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
#
|
||||
# Copyright (C) 2021 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.
|
||||
|
||||
# New priorities are defined here:
|
||||
# - https://firebase.google.com/docs/reference/fcm/rest/v1/\
|
||||
# projects.messages#NotificationPriority
|
||||
|
||||
# Legacy priorities are defined here:
|
||||
# - https://firebase.google.com/docs/cloud-messaging/http-server-ref
|
||||
from .common import (FCMMode, FCM_MODES)
|
||||
from ...logger import logger
|
||||
|
||||
|
||||
class NotificationPriority(object):
|
||||
"""
|
||||
Defines the Notification Priorities as described on:
|
||||
https://firebase.google.com/docs/reference/fcm/rest/v1/\
|
||||
projects.messages#androidmessagepriority
|
||||
|
||||
NORMAL:
|
||||
Default priority for data messages. Normal priority messages won't
|
||||
open network connections on a sleeping device, and their delivery
|
||||
may be delayed to conserve the battery. For less time-sensitive
|
||||
messages, such as notifications of new email or other data to sync,
|
||||
choose normal delivery priority.
|
||||
|
||||
HIGH:
|
||||
Default priority for notification messages. FCM attempts to
|
||||
deliver high priority messages immediately, allowing the FCM
|
||||
service to wake a sleeping device when possible and open a network
|
||||
connection to your app server. Apps with instant messaging, chat,
|
||||
or voice call alerts, for example, generally need to open a
|
||||
network connection and make sure FCM delivers the message to the
|
||||
device without delay. Set high priority if the message is
|
||||
time-critical and requires the user's immediate interaction, but
|
||||
beware that setting your messages to high priority contributes
|
||||
more to battery drain compared with normal priority messages.
|
||||
"""
|
||||
|
||||
NORMAL = 'NORMAL'
|
||||
HIGH = 'HIGH'
|
||||
|
||||
|
||||
class FCMPriority(object):
|
||||
"""
|
||||
Defines our accepted priorites
|
||||
"""
|
||||
MIN = "min"
|
||||
|
||||
LOW = "low"
|
||||
|
||||
NORMAL = "normal"
|
||||
|
||||
HIGH = "high"
|
||||
|
||||
MAX = "max"
|
||||
|
||||
|
||||
FCM_PRIORITIES = (
|
||||
FCMPriority.MIN,
|
||||
FCMPriority.LOW,
|
||||
FCMPriority.NORMAL,
|
||||
FCMPriority.HIGH,
|
||||
FCMPriority.MAX,
|
||||
)
|
||||
|
||||
|
||||
class FCMPriorityManager(object):
|
||||
"""
|
||||
A Simple object to make it easier to work with FCM set priorities
|
||||
"""
|
||||
|
||||
priority_map = {
|
||||
FCMPriority.MIN: {
|
||||
FCMMode.OAuth2: {
|
||||
'message': {
|
||||
'android': {
|
||||
'priority': NotificationPriority.NORMAL
|
||||
},
|
||||
'apns': {
|
||||
'headers': {
|
||||
'apns-priority': "5"
|
||||
}
|
||||
},
|
||||
'webpush': {
|
||||
'headers': {
|
||||
'Urgency': 'very-low'
|
||||
}
|
||||
},
|
||||
}
|
||||
},
|
||||
FCMMode.Legacy: {
|
||||
'priority': 'normal',
|
||||
}
|
||||
},
|
||||
FCMPriority.LOW: {
|
||||
FCMMode.OAuth2: {
|
||||
'message': {
|
||||
'android': {
|
||||
'priority': NotificationPriority.NORMAL
|
||||
},
|
||||
'apns': {
|
||||
'headers': {
|
||||
'apns-priority': "5"
|
||||
}
|
||||
},
|
||||
'webpush': {
|
||||
'headers': {
|
||||
'Urgency': 'low'
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
FCMMode.Legacy: {
|
||||
'priority': 'normal',
|
||||
}
|
||||
},
|
||||
FCMPriority.NORMAL: {
|
||||
FCMMode.OAuth2: {
|
||||
'message': {
|
||||
'android': {
|
||||
'priority': NotificationPriority.NORMAL
|
||||
},
|
||||
'apns': {
|
||||
'headers': {
|
||||
'apns-priority': "5"
|
||||
}
|
||||
},
|
||||
'webpush': {
|
||||
'headers': {
|
||||
'Urgency': 'normal'
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
FCMMode.Legacy: {
|
||||
'priority': 'normal',
|
||||
}
|
||||
},
|
||||
FCMPriority.HIGH: {
|
||||
FCMMode.OAuth2: {
|
||||
'message': {
|
||||
'android': {
|
||||
'priority': NotificationPriority.HIGH
|
||||
},
|
||||
'apns': {
|
||||
'headers': {
|
||||
'apns-priority': "10"
|
||||
}
|
||||
},
|
||||
'webpush': {
|
||||
'headers': {
|
||||
'Urgency': 'high'
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
FCMMode.Legacy: {
|
||||
'priority': 'high',
|
||||
}
|
||||
},
|
||||
FCMPriority.MAX: {
|
||||
FCMMode.OAuth2: {
|
||||
'message': {
|
||||
'android': {
|
||||
'priority': NotificationPriority.HIGH
|
||||
},
|
||||
'apns': {
|
||||
'headers': {
|
||||
'apns-priority': "10"
|
||||
}
|
||||
},
|
||||
'webpush': {
|
||||
'headers': {
|
||||
'Urgency': 'high'
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
FCMMode.Legacy: {
|
||||
'priority': 'high',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
def __init__(self, mode, priority=None):
|
||||
"""
|
||||
Takes a FCMMode and Priority
|
||||
"""
|
||||
|
||||
self.mode = mode
|
||||
if self.mode not in FCM_MODES:
|
||||
msg = 'The FCM mode specified ({}) is invalid.'.format(mode)
|
||||
logger.warning(msg)
|
||||
raise TypeError(msg)
|
||||
|
||||
self.priority = None
|
||||
if priority:
|
||||
self.priority = \
|
||||
next((p for p in FCM_PRIORITIES
|
||||
if p.startswith(priority[:2].lower())), None)
|
||||
if not self.priority:
|
||||
msg = 'An invalid FCM Priority ' \
|
||||
'({}) was specified.'.format(priority)
|
||||
logger.warning(msg)
|
||||
raise TypeError(msg)
|
||||
|
||||
def payload(self):
|
||||
"""
|
||||
Returns our payload depending on our mode
|
||||
"""
|
||||
return self.priority_map[self.priority][self.mode] \
|
||||
if self.priority else {}
|
||||
|
||||
def __str__(self):
|
||||
"""
|
||||
our priority representation
|
||||
"""
|
||||
return self.priority if self.priority else ''
|
||||
|
||||
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
|
||||
"""
|
||||
return True if self.priority else False
|
393
libs/apprise/plugins/NotifyForm.py
Normal file
393
libs/apprise/plugins/NotifyForm.py
Normal file
|
@ -0,0 +1,393 @@
|
|||
# -*- 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 six
|
||||
import requests
|
||||
|
||||
from .NotifyBase import NotifyBase
|
||||
from ..URLBase import PrivacyMode
|
||||
from ..common import NotifyImageSize
|
||||
from ..common import NotifyType
|
||||
from ..AppriseLocale import gettext_lazy as _
|
||||
|
||||
|
||||
# Defines the method to send the notification
|
||||
METHODS = (
|
||||
'POST',
|
||||
'GET',
|
||||
'DELETE',
|
||||
'PUT',
|
||||
'HEAD'
|
||||
)
|
||||
|
||||
|
||||
class NotifyForm(NotifyBase):
|
||||
"""
|
||||
A wrapper for Form Notifications
|
||||
"""
|
||||
|
||||
# The default descriptive name associated with the Notification
|
||||
service_name = 'Form'
|
||||
|
||||
# The default protocol
|
||||
protocol = 'form'
|
||||
|
||||
# The default secure protocol
|
||||
secure_protocol = 'forms'
|
||||
|
||||
# A URL that takes you to the setup/help of the specific protocol
|
||||
setup_url = 'https://github.com/caronc/apprise/wiki/Notify_Custom_Form'
|
||||
|
||||
# Allows the user to specify the NotifyImageSize object
|
||||
image_size = NotifyImageSize.XY_128
|
||||
|
||||
# Disable throttle rate for Form requests since they are normally
|
||||
# local anyway
|
||||
request_rate_per_sec = 0
|
||||
|
||||
# Define object templates
|
||||
templates = (
|
||||
'{schema}://{host}',
|
||||
'{schema}://{host}:{port}',
|
||||
'{schema}://{user}@{host}',
|
||||
'{schema}://{user}@{host}:{port}',
|
||||
'{schema}://{user}:{password}@{host}',
|
||||
'{schema}://{user}:{password}@{host}:{port}',
|
||||
)
|
||||
|
||||
# Define our tokens; these are the minimum tokens required required to
|
||||
# be passed into this function (as arguments). The syntax appends any
|
||||
# previously defined in the base package and builds onto them
|
||||
template_tokens = dict(NotifyBase.template_tokens, **{
|
||||
'host': {
|
||||
'name': _('Hostname'),
|
||||
'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,
|
||||
},
|
||||
|
||||
})
|
||||
|
||||
# Define our template arguments
|
||||
template_args = dict(NotifyBase.template_args, **{
|
||||
'method': {
|
||||
'name': _('Fetch Method'),
|
||||
'type': 'choice:string',
|
||||
'values': METHODS,
|
||||
'default': METHODS[0],
|
||||
},
|
||||
})
|
||||
|
||||
# Define any kwargs we're using
|
||||
template_kwargs = {
|
||||
'headers': {
|
||||
'name': _('HTTP Header'),
|
||||
'prefix': '+',
|
||||
},
|
||||
'payload': {
|
||||
'name': _('Payload Extras'),
|
||||
'prefix': ':',
|
||||
},
|
||||
}
|
||||
|
||||
def __init__(self, headers=None, method=None, payload=None, **kwargs):
|
||||
"""
|
||||
Initialize Form Object
|
||||
|
||||
headers can be a dictionary of key/value pairs that you want to
|
||||
additionally include as part of the server headers to post with
|
||||
|
||||
"""
|
||||
super(NotifyForm, self).__init__(**kwargs)
|
||||
|
||||
self.fullpath = kwargs.get('fullpath')
|
||||
if not isinstance(self.fullpath, six.string_types):
|
||||
self.fullpath = ''
|
||||
|
||||
self.method = self.template_args['method']['default'] \
|
||||
if not isinstance(method, six.string_types) else method.upper()
|
||||
|
||||
if self.method not in METHODS:
|
||||
msg = 'The method specified ({}) is invalid.'.format(method)
|
||||
self.logger.warning(msg)
|
||||
raise TypeError(msg)
|
||||
|
||||
self.headers = {}
|
||||
if headers:
|
||||
# Store our extra headers
|
||||
self.headers.update(headers)
|
||||
|
||||
self.payload_extras = {}
|
||||
if payload:
|
||||
# Store our extra payload entries
|
||||
self.payload_extras.update(payload)
|
||||
|
||||
return
|
||||
|
||||
def url(self, privacy=False, *args, **kwargs):
|
||||
"""
|
||||
Returns the URL built dynamically based on specified arguments.
|
||||
"""
|
||||
|
||||
# Define any URL parameters
|
||||
params = {
|
||||
'method': self.method,
|
||||
}
|
||||
|
||||
# Extend our parameters
|
||||
params.update(self.url_parameters(privacy=privacy, *args, **kwargs))
|
||||
|
||||
# Append our headers into our parameters
|
||||
params.update({'+{}'.format(k): v for k, v in self.headers.items()})
|
||||
|
||||
# Append our payload extra's into our parameters
|
||||
params.update(
|
||||
{':{}'.format(k): v for k, v in self.payload_extras.items()})
|
||||
|
||||
# Determine Authentication
|
||||
auth = ''
|
||||
if self.user and self.password:
|
||||
auth = '{user}:{password}@'.format(
|
||||
user=NotifyForm.quote(self.user, safe=''),
|
||||
password=self.pprint(
|
||||
self.password, privacy, mode=PrivacyMode.Secret, safe=''),
|
||||
)
|
||||
elif self.user:
|
||||
auth = '{user}@'.format(
|
||||
user=NotifyForm.quote(self.user, safe=''),
|
||||
)
|
||||
|
||||
default_port = 443 if self.secure else 80
|
||||
|
||||
return '{schema}://{auth}{hostname}{port}{fullpath}?{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),
|
||||
fullpath=NotifyForm.quote(self.fullpath, safe='/')
|
||||
if self.fullpath else '/',
|
||||
params=NotifyForm.urlencode(params),
|
||||
)
|
||||
|
||||
def send(self, body, title='', notify_type=NotifyType.INFO, attach=None,
|
||||
**kwargs):
|
||||
"""
|
||||
Perform Form Notification
|
||||
"""
|
||||
|
||||
headers = {
|
||||
'User-Agent': self.app_id,
|
||||
}
|
||||
|
||||
# Apply any/all header over-rides defined
|
||||
headers.update(self.headers)
|
||||
|
||||
# Track our potential attachments
|
||||
files = []
|
||||
if attach:
|
||||
for no, attachment in enumerate(attach, start=1):
|
||||
# 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:
|
||||
files.append((
|
||||
'file{:02d}'.format(no), (
|
||||
attachment.name,
|
||||
open(attachment.path, 'rb'),
|
||||
attachment.mimetype)
|
||||
))
|
||||
|
||||
except (OSError, IOError) as e:
|
||||
self.logger.warning(
|
||||
'An I/O error occurred while opening {}.'.format(
|
||||
attachment.name if attachment else 'attachment'))
|
||||
self.logger.debug('I/O Exception: %s' % str(e))
|
||||
return False
|
||||
|
||||
finally:
|
||||
for file in files:
|
||||
# Ensure all files are closed
|
||||
if file[1][1]:
|
||||
file[1][1].close()
|
||||
|
||||
# prepare Form Object
|
||||
payload = {
|
||||
# Version: Major.Minor, Major is only updated if the entire
|
||||
# schema is changed. If just adding new items (or removing
|
||||
# old ones, only increment the Minor!
|
||||
'version': '1.0',
|
||||
'title': title,
|
||||
'message': body,
|
||||
'type': notify_type,
|
||||
}
|
||||
|
||||
# Apply any/all payload over-rides defined
|
||||
payload.update(self.payload_extras)
|
||||
|
||||
auth = None
|
||||
if self.user:
|
||||
auth = (self.user, self.password)
|
||||
|
||||
# Set our schema
|
||||
schema = 'https' if self.secure else 'http'
|
||||
|
||||
url = '%s://%s' % (schema, self.host)
|
||||
if isinstance(self.port, int):
|
||||
url += ':%d' % self.port
|
||||
|
||||
url += self.fullpath
|
||||
|
||||
self.logger.debug('Form %s URL: %s (cert_verify=%r)' % (
|
||||
self.method, url, self.verify_certificate,
|
||||
))
|
||||
self.logger.debug('Form Payload: %s' % str(payload))
|
||||
|
||||
# Always call throttle before any remote server i/o is made
|
||||
self.throttle()
|
||||
|
||||
if self.method == 'GET':
|
||||
method = requests.get
|
||||
|
||||
elif self.method == 'PUT':
|
||||
method = requests.put
|
||||
|
||||
elif self.method == 'DELETE':
|
||||
method = requests.delete
|
||||
|
||||
elif self.method == 'HEAD':
|
||||
method = requests.head
|
||||
|
||||
else: # POST
|
||||
method = requests.post
|
||||
|
||||
try:
|
||||
r = method(
|
||||
url,
|
||||
files=None if not files else files,
|
||||
data=payload if self.method != 'GET' else None,
|
||||
params=payload if self.method == 'GET' else None,
|
||||
headers=headers,
|
||||
auth=auth,
|
||||
verify=self.verify_certificate,
|
||||
timeout=self.request_timeout,
|
||||
)
|
||||
if r.status_code < 200 or r.status_code >= 300:
|
||||
# We had a problem
|
||||
status_str = \
|
||||
NotifyForm.http_response_code_lookup(r.status_code)
|
||||
|
||||
self.logger.warning(
|
||||
'Failed to send Form %s notification: %s%serror=%s.',
|
||||
self.method,
|
||||
status_str,
|
||||
', ' if status_str else '',
|
||||
str(r.status_code))
|
||||
|
||||
self.logger.debug('Response Details:\r\n{}'.format(r.content))
|
||||
|
||||
# Return; we're done
|
||||
return False
|
||||
|
||||
else:
|
||||
self.logger.info('Sent Form %s notification.', self.method)
|
||||
|
||||
except requests.RequestException as e:
|
||||
self.logger.warning(
|
||||
'A Connection error occurred sending Form '
|
||||
'notification to %s.' % self.host)
|
||||
self.logger.debug('Socket Exception: %s' % str(e))
|
||||
|
||||
# Return; we're done
|
||||
return False
|
||||
|
||||
except (OSError, IOError) as e:
|
||||
self.logger.warning(
|
||||
'An I/O error occurred while reading one of the '
|
||||
'attached files.')
|
||||
self.logger.debug('I/O Exception: %s' % str(e))
|
||||
return False
|
||||
|
||||
finally:
|
||||
for file in files:
|
||||
# Ensure all files are closed
|
||||
file[1][1].close()
|
||||
|
||||
return True
|
||||
|
||||
@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
|
||||
|
||||
# store any additional payload extra's defined
|
||||
results['payload'] = {NotifyForm.unquote(x): NotifyForm.unquote(y)
|
||||
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
|
||||
results['headers'] = {NotifyForm.unquote(x): NotifyForm.unquote(y)
|
||||
for x, y in results['headers'].items()}
|
||||
|
||||
# Set method if not otherwise set
|
||||
if 'method' in results['qsd'] and len(results['qsd']['method']):
|
||||
results['method'] = NotifyForm.unquote(results['qsd']['method'])
|
||||
|
||||
return results
|
|
@ -170,11 +170,6 @@ class NotifyGotify(NotifyBase):
|
|||
# Append our remaining path
|
||||
url += '{fullpath}message'.format(fullpath=self.fullpath)
|
||||
|
||||
# Define our parameteers
|
||||
params = {
|
||||
'token': self.token,
|
||||
}
|
||||
|
||||
# Prepare Gotify Object
|
||||
payload = {
|
||||
'priority': self.priority,
|
||||
|
@ -193,6 +188,7 @@ class NotifyGotify(NotifyBase):
|
|||
headers = {
|
||||
'User-Agent': self.app_id,
|
||||
'Content-Type': 'application/json',
|
||||
'X-Gotify-Key': self.token,
|
||||
}
|
||||
|
||||
self.logger.debug('Gotify POST URL: %s (cert_verify=%r)' % (
|
||||
|
@ -206,7 +202,6 @@ class NotifyGotify(NotifyBase):
|
|||
try:
|
||||
r = requests.post(
|
||||
url,
|
||||
params=params,
|
||||
data=dumps(payload),
|
||||
headers=headers,
|
||||
verify=self.verify_certificate,
|
||||
|
|
|
@ -35,6 +35,16 @@ from ..common import NotifyType
|
|||
from ..AppriseLocale import gettext_lazy as _
|
||||
|
||||
|
||||
# Defines the method to send the notification
|
||||
METHODS = (
|
||||
'POST',
|
||||
'GET',
|
||||
'DELETE',
|
||||
'PUT',
|
||||
'HEAD'
|
||||
)
|
||||
|
||||
|
||||
class NotifyJSON(NotifyBase):
|
||||
"""
|
||||
A wrapper for JSON Notifications
|
||||
|
@ -93,6 +103,17 @@ class NotifyJSON(NotifyBase):
|
|||
'type': 'string',
|
||||
'private': True,
|
||||
},
|
||||
|
||||
})
|
||||
|
||||
# Define our template arguments
|
||||
template_args = dict(NotifyBase.template_args, **{
|
||||
'method': {
|
||||
'name': _('Fetch Method'),
|
||||
'type': 'choice:string',
|
||||
'values': METHODS,
|
||||
'default': METHODS[0],
|
||||
},
|
||||
})
|
||||
|
||||
# Define any kwargs we're using
|
||||
|
@ -101,9 +122,13 @@ class NotifyJSON(NotifyBase):
|
|||
'name': _('HTTP Header'),
|
||||
'prefix': '+',
|
||||
},
|
||||
'payload': {
|
||||
'name': _('Payload Extras'),
|
||||
'prefix': ':',
|
||||
},
|
||||
}
|
||||
|
||||
def __init__(self, headers=None, **kwargs):
|
||||
def __init__(self, headers=None, method=None, payload=None, **kwargs):
|
||||
"""
|
||||
Initialize JSON Object
|
||||
|
||||
|
@ -115,13 +140,26 @@ class NotifyJSON(NotifyBase):
|
|||
|
||||
self.fullpath = kwargs.get('fullpath')
|
||||
if not isinstance(self.fullpath, six.string_types):
|
||||
self.fullpath = '/'
|
||||
self.fullpath = ''
|
||||
|
||||
self.method = self.template_args['method']['default'] \
|
||||
if not isinstance(method, six.string_types) else method.upper()
|
||||
|
||||
if self.method not in METHODS:
|
||||
msg = 'The method specified ({}) is invalid.'.format(method)
|
||||
self.logger.warning(msg)
|
||||
raise TypeError(msg)
|
||||
|
||||
self.headers = {}
|
||||
if headers:
|
||||
# Store our extra headers
|
||||
self.headers.update(headers)
|
||||
|
||||
self.payload_extras = {}
|
||||
if payload:
|
||||
# Store our extra payload entries
|
||||
self.payload_extras.update(payload)
|
||||
|
||||
return
|
||||
|
||||
def url(self, privacy=False, *args, **kwargs):
|
||||
|
@ -129,12 +167,21 @@ class NotifyJSON(NotifyBase):
|
|||
Returns the URL built dynamically based on specified arguments.
|
||||
"""
|
||||
|
||||
# Our URL parameters
|
||||
params = self.url_parameters(privacy=privacy, *args, **kwargs)
|
||||
# Define any URL parameters
|
||||
params = {
|
||||
'method': self.method,
|
||||
}
|
||||
|
||||
# Extend our parameters
|
||||
params.update(self.url_parameters(privacy=privacy, *args, **kwargs))
|
||||
|
||||
# Append our headers into our parameters
|
||||
params.update({'+{}'.format(k): v for k, v in self.headers.items()})
|
||||
|
||||
# Append our payload extra's into our parameters
|
||||
params.update(
|
||||
{':{}'.format(k): v for k, v in self.payload_extras.items()})
|
||||
|
||||
# Determine Authentication
|
||||
auth = ''
|
||||
if self.user and self.password:
|
||||
|
@ -150,14 +197,15 @@ class NotifyJSON(NotifyBase):
|
|||
|
||||
default_port = 443 if self.secure else 80
|
||||
|
||||
return '{schema}://{auth}{hostname}{port}{fullpath}/?{params}'.format(
|
||||
return '{schema}://{auth}{hostname}{port}{fullpath}?{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),
|
||||
fullpath=NotifyJSON.quote(self.fullpath, safe='/'),
|
||||
fullpath=NotifyJSON.quote(self.fullpath, safe='/')
|
||||
if self.fullpath else '/',
|
||||
params=NotifyJSON.urlencode(params),
|
||||
)
|
||||
|
||||
|
@ -217,6 +265,9 @@ class NotifyJSON(NotifyBase):
|
|||
'type': notify_type,
|
||||
}
|
||||
|
||||
# Apply any/all payload over-rides defined
|
||||
payload.update(self.payload_extras)
|
||||
|
||||
auth = None
|
||||
if self.user:
|
||||
auth = (self.user, self.password)
|
||||
|
@ -238,8 +289,23 @@ class NotifyJSON(NotifyBase):
|
|||
# Always call throttle before any remote server i/o is made
|
||||
self.throttle()
|
||||
|
||||
if self.method == 'GET':
|
||||
method = requests.get
|
||||
|
||||
elif self.method == 'PUT':
|
||||
method = requests.put
|
||||
|
||||
elif self.method == 'DELETE':
|
||||
method = requests.delete
|
||||
|
||||
elif self.method == 'HEAD':
|
||||
method = requests.head
|
||||
|
||||
else: # POST
|
||||
method = requests.post
|
||||
|
||||
try:
|
||||
r = requests.post(
|
||||
r = method(
|
||||
url,
|
||||
data=dumps(payload),
|
||||
headers=headers,
|
||||
|
@ -247,17 +313,17 @@ class NotifyJSON(NotifyBase):
|
|||
verify=self.verify_certificate,
|
||||
timeout=self.request_timeout,
|
||||
)
|
||||
if r.status_code != requests.codes.ok:
|
||||
if r.status_code < 200 or r.status_code >= 300:
|
||||
# We had a problem
|
||||
status_str = \
|
||||
NotifyJSON.http_response_code_lookup(r.status_code)
|
||||
|
||||
self.logger.warning(
|
||||
'Failed to send JSON notification: '
|
||||
'{}{}error={}.'.format(
|
||||
status_str,
|
||||
', ' if status_str else '',
|
||||
r.status_code))
|
||||
'Failed to send JSON %s notification: %s%serror=%s.',
|
||||
self.method,
|
||||
status_str,
|
||||
', ' if status_str else '',
|
||||
str(r.status_code))
|
||||
|
||||
self.logger.debug('Response Details:\r\n{}'.format(r.content))
|
||||
|
||||
|
@ -265,7 +331,7 @@ class NotifyJSON(NotifyBase):
|
|||
return False
|
||||
|
||||
else:
|
||||
self.logger.info('Sent JSON notification.')
|
||||
self.logger.info('Sent JSON %s notification.', self.method)
|
||||
|
||||
except requests.RequestException as e:
|
||||
self.logger.warning(
|
||||
|
@ -290,6 +356,10 @@ class NotifyJSON(NotifyBase):
|
|||
# We're done early as we couldn't load the results
|
||||
return results
|
||||
|
||||
# store any additional payload extra's defined
|
||||
results['payload'] = {NotifyJSON.unquote(x): NotifyJSON.unquote(y)
|
||||
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+']
|
||||
|
@ -303,4 +373,8 @@ class NotifyJSON(NotifyBase):
|
|||
results['headers'] = {NotifyJSON.unquote(x): NotifyJSON.unquote(y)
|
||||
for x, y in results['headers'].items()}
|
||||
|
||||
# Set method if not otherwise set
|
||||
if 'method' in results['qsd'] and len(results['qsd']['method']):
|
||||
results['method'] = NotifyJSON.unquote(results['qsd']['method'])
|
||||
|
||||
return results
|
||||
|
|
|
@ -91,8 +91,11 @@ class NotifyMacOSX(NotifyBase):
|
|||
# content to display
|
||||
body_max_line_count = 10
|
||||
|
||||
# The path to the terminal-notifier
|
||||
notify_path = '/usr/local/bin/terminal-notifier'
|
||||
# The possible paths to the terminal-notifier
|
||||
notify_paths = (
|
||||
'/opt/homebrew/bin/terminal-notifier',
|
||||
'/usr/local/bin/terminal-notifier',
|
||||
)
|
||||
|
||||
# Define object templates
|
||||
templates = (
|
||||
|
@ -127,6 +130,10 @@ class NotifyMacOSX(NotifyBase):
|
|||
# or not.
|
||||
self.include_image = include_image
|
||||
|
||||
# Acquire the notify path
|
||||
self.notify_path = next( # pragma: no branch
|
||||
(p for p in self.notify_paths if os.access(p, os.X_OK)), None)
|
||||
|
||||
# Set sound object (no q/a for now)
|
||||
self.sound = sound
|
||||
return
|
||||
|
@ -136,10 +143,11 @@ class NotifyMacOSX(NotifyBase):
|
|||
Perform MacOSX Notification
|
||||
"""
|
||||
|
||||
if not os.access(self.notify_path, os.X_OK):
|
||||
if not (self.notify_path and os.access(self.notify_path, os.X_OK)):
|
||||
self.logger.warning(
|
||||
"MacOSX Notifications require '{}' to be in place."
|
||||
.format(self.notify_path))
|
||||
"MacOSX Notifications requires one of the following to "
|
||||
"be in place: '{}'.".format(
|
||||
'\', \''.join(self.notify_paths)))
|
||||
return False
|
||||
|
||||
# Start with our notification path
|
||||
|
|
|
@ -42,7 +42,7 @@ from ..common import NotifyImageSize
|
|||
from ..common import NotifyFormat
|
||||
from ..utils import parse_bool
|
||||
from ..utils import parse_list
|
||||
from ..utils import apply_template
|
||||
from ..utils import is_hostname
|
||||
from ..utils import validate_regex
|
||||
from ..AppriseLocale import gettext_lazy as _
|
||||
|
||||
|
@ -287,6 +287,22 @@ class NotifyMatrix(NotifyBase):
|
|||
self.logger.warning(msg)
|
||||
raise TypeError(msg)
|
||||
|
||||
elif not is_hostname(self.host):
|
||||
msg = 'An invalid Matrix Hostname ({}) was specified'\
|
||||
.format(self.host)
|
||||
self.logger.warning(msg)
|
||||
raise TypeError(msg)
|
||||
else:
|
||||
# Verify port if specified
|
||||
if self.port is not None and not (
|
||||
isinstance(self.port, int)
|
||||
and self.port >= self.template_tokens['port']['min']
|
||||
and self.port <= self.template_tokens['port']['max']):
|
||||
msg = 'An invalid Matrix Port ({}) was specified'\
|
||||
.format(self.port)
|
||||
self.logger.warning(msg)
|
||||
raise TypeError(msg)
|
||||
|
||||
def send(self, body, title='', notify_type=NotifyType.INFO, **kwargs):
|
||||
"""
|
||||
Perform Matrix Notification
|
||||
|
@ -453,21 +469,16 @@ class NotifyMatrix(NotifyBase):
|
|||
}
|
||||
|
||||
if self.notify_format == NotifyFormat.HTML:
|
||||
# Add additional information to our content; use {{app_title}}
|
||||
# to apply the title to the html body
|
||||
tokens = {
|
||||
'app_title': NotifyMatrix.escape_html(
|
||||
title, whitespace=False),
|
||||
}
|
||||
payload['text'] = apply_template(body, **tokens)
|
||||
payload['text'] = '{title}{body}'.format(
|
||||
title='' if not title else '<h1>{}</h1>'.format(
|
||||
NotifyMatrix.escape_html(title)),
|
||||
body=body)
|
||||
|
||||
elif self.notify_format == NotifyFormat.MARKDOWN:
|
||||
# Add additional information to our content; use {{app_title}}
|
||||
# to apply the title to the html body
|
||||
tokens = {
|
||||
'app_title': title,
|
||||
}
|
||||
payload['text'] = markdown(apply_template(body, **tokens))
|
||||
payload['text'] = '{title}{body}'.format(
|
||||
title='' if not title else '<h1>{}</h1>'.format(
|
||||
NotifyMatrix.escape_html(title)),
|
||||
body=markdown(body))
|
||||
|
||||
else: # NotifyFormat.TEXT
|
||||
payload['text'] = \
|
||||
|
@ -566,32 +577,29 @@ class NotifyMatrix(NotifyBase):
|
|||
payload = {
|
||||
'msgtype': 'm.{}'.format(self.msgtype),
|
||||
'body': '{title}{body}'.format(
|
||||
title='' if not title else '{}\r\n'.format(title),
|
||||
title='' if not title else '# {}\r\n'.format(title),
|
||||
body=body),
|
||||
}
|
||||
|
||||
# Update our payload advance formatting for the services that
|
||||
# support them.
|
||||
if self.notify_format == NotifyFormat.HTML:
|
||||
# Add additional information to our content; use {{app_title}}
|
||||
# to apply the title to the html body
|
||||
tokens = {
|
||||
'app_title': NotifyMatrix.escape_html(
|
||||
title, whitespace=False),
|
||||
}
|
||||
|
||||
payload.update({
|
||||
'format': 'org.matrix.custom.html',
|
||||
'formatted_body': apply_template(body, **tokens),
|
||||
'formatted_body': '{title}{body}'.format(
|
||||
title='' if not title else '<h1>{}</h1>'.format(title),
|
||||
body=body,
|
||||
)
|
||||
})
|
||||
|
||||
elif self.notify_format == NotifyFormat.MARKDOWN:
|
||||
tokens = {
|
||||
'app_title': title,
|
||||
}
|
||||
payload.update({
|
||||
'format': 'org.matrix.custom.html',
|
||||
'formatted_body': markdown(apply_template(body, **tokens))
|
||||
'formatted_body': '{title}{body}'.format(
|
||||
title='' if not title else '<h1>{}</h1>'.format(
|
||||
NotifyMatrix.escape_html(title, whitespace=False)),
|
||||
body=markdown(body),
|
||||
)
|
||||
})
|
||||
|
||||
# Build our path
|
||||
|
|
281
libs/apprise/plugins/NotifyNextcloudTalk.py
Normal file
281
libs/apprise/plugins/NotifyNextcloudTalk.py
Normal file
|
@ -0,0 +1,281 @@
|
|||
# -*- 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 CON
|
||||
|
||||
import requests
|
||||
|
||||
from .NotifyBase import NotifyBase
|
||||
from ..URLBase import PrivacyMode
|
||||
from ..common import NotifyType
|
||||
from ..utils import parse_list
|
||||
from ..AppriseLocale import gettext_lazy as _
|
||||
|
||||
|
||||
class NotifyNextcloudTalk(NotifyBase):
|
||||
"""
|
||||
A wrapper for Nextcloud Talk Notifications
|
||||
"""
|
||||
|
||||
# The default descriptive name associated with the Notification
|
||||
service_name = _('Nextcloud Talk')
|
||||
|
||||
# The services URL
|
||||
service_url = 'https://nextcloud.com/talk'
|
||||
|
||||
# Insecure protocol (for those self hosted requests)
|
||||
protocol = 'nctalk'
|
||||
|
||||
# The default protocol (this is secure for notica)
|
||||
secure_protocol = 'nctalks'
|
||||
|
||||
# A URL that takes you to the setup/help of the specific protocol
|
||||
setup_url = 'https://github.com/caronc/apprise/wiki/Notify_nextcloudtalk'
|
||||
|
||||
# Nextcloud title length
|
||||
title_maxlen = 255
|
||||
|
||||
# Defines the maximum allowable characters per message.
|
||||
body_maxlen = 4000
|
||||
|
||||
# Define object templates
|
||||
templates = (
|
||||
'{schema}://{user}:{password}@{host}/{targets}',
|
||||
'{schema}://{user}:{password}@{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,
|
||||
},
|
||||
'user': {
|
||||
'name': _('Username'),
|
||||
'type': 'string',
|
||||
'required': True,
|
||||
},
|
||||
'password': {
|
||||
'name': _('Password'),
|
||||
'type': 'string',
|
||||
'private': True,
|
||||
'required': True,
|
||||
},
|
||||
'targets': {
|
||||
'name': _('Targets'),
|
||||
'type': 'list:string',
|
||||
'required': True,
|
||||
},
|
||||
})
|
||||
|
||||
# Define any kwargs we're using
|
||||
template_kwargs = {
|
||||
'headers': {
|
||||
'name': _('HTTP Header'),
|
||||
'prefix': '+',
|
||||
},
|
||||
}
|
||||
|
||||
def __init__(self, targets=None, headers=None, **kwargs):
|
||||
"""
|
||||
Initialize Nextcloud Talk Object
|
||||
"""
|
||||
super(NotifyNextcloudTalk, self).__init__(**kwargs)
|
||||
|
||||
if self.user is None or self.password is None:
|
||||
msg = 'User and password have to be specified.'
|
||||
self.logger.warning(msg)
|
||||
raise TypeError(msg)
|
||||
|
||||
self.targets = parse_list(targets)
|
||||
if len(self.targets) == 0:
|
||||
msg = 'At least one Nextcloud Talk Room ID must be specified.'
|
||||
self.logger.warning(msg)
|
||||
raise TypeError(msg)
|
||||
|
||||
self.headers = {}
|
||||
if headers:
|
||||
# Store our extra headers
|
||||
self.headers.update(headers)
|
||||
|
||||
return
|
||||
|
||||
def send(self, body, title='', notify_type=NotifyType.INFO, **kwargs):
|
||||
"""
|
||||
Perform Nextcloud Talk Notification
|
||||
"""
|
||||
|
||||
# Prepare our Header
|
||||
headers = {
|
||||
'User-Agent': self.app_id,
|
||||
'OCS-APIREQUEST': 'true',
|
||||
}
|
||||
|
||||
# Apply any/all header over-rides defined
|
||||
headers.update(self.headers)
|
||||
|
||||
# error tracking (used for function return)
|
||||
has_error = False
|
||||
|
||||
# Create a copy of the targets list
|
||||
targets = list(self.targets)
|
||||
while len(targets):
|
||||
target = targets.pop(0)
|
||||
|
||||
# Prepare our Payload
|
||||
if not body:
|
||||
payload = {
|
||||
'message': title if title else self.app_desc,
|
||||
}
|
||||
else:
|
||||
payload = {
|
||||
'message': title + '\r\n' + body
|
||||
if title else self.app_desc + '\r\n' + body,
|
||||
}
|
||||
|
||||
# Nextcloud Talk URL
|
||||
notify_url = '{schema}://{host}'\
|
||||
'/ocs/v2.php/apps/spreed/api/v1/chat/{target}'
|
||||
|
||||
notify_url = notify_url.format(
|
||||
schema='https' if self.secure else 'http',
|
||||
host=self.host if not isinstance(self.port, int)
|
||||
else '{}:{}'.format(self.host, self.port),
|
||||
target=target,
|
||||
)
|
||||
|
||||
self.logger.debug(
|
||||
'Nextcloud Talk POST URL: %s (cert_verify=%r)',
|
||||
notify_url, self.verify_certificate)
|
||||
self.logger.debug(
|
||||
'Nextcloud Talk Payload: %s',
|
||||
str(payload))
|
||||
|
||||
# Always call throttle before any remote server i/o is made
|
||||
self.throttle()
|
||||
|
||||
try:
|
||||
r = requests.post(
|
||||
notify_url,
|
||||
data=payload,
|
||||
headers=headers,
|
||||
auth=(self.user, self.password),
|
||||
verify=self.verify_certificate,
|
||||
timeout=self.request_timeout,
|
||||
)
|
||||
if r.status_code != requests.codes.created:
|
||||
# We had a problem
|
||||
status_str = \
|
||||
NotifyNextcloudTalk.http_response_code_lookup(
|
||||
r.status_code)
|
||||
|
||||
self.logger.warning(
|
||||
'Failed to send Nextcloud Talk notification:'
|
||||
'{}{}error={}.'.format(
|
||||
status_str,
|
||||
', ' if status_str else '',
|
||||
r.status_code))
|
||||
|
||||
self.logger.debug(
|
||||
'Response Details:\r\n{}'.format(r.content))
|
||||
# track our failure
|
||||
has_error = True
|
||||
continue
|
||||
|
||||
else:
|
||||
self.logger.info(
|
||||
'Sent Nextcloud Talk notification.')
|
||||
|
||||
except requests.RequestException as e:
|
||||
self.logger.warning(
|
||||
'A Connection error occurred sending Nextcloud Talk '
|
||||
'notification.')
|
||||
self.logger.debug('Socket Exception: %s' % str(e))
|
||||
|
||||
# track 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.
|
||||
"""
|
||||
|
||||
# Determine Authentication
|
||||
auth = '{user}:{password}@'.format(
|
||||
user=NotifyNextcloudTalk.quote(self.user, safe=''),
|
||||
password=self.pprint(
|
||||
self.password, privacy, mode=PrivacyMode.Secret, safe=''),
|
||||
)
|
||||
|
||||
default_port = 443 if self.secure else 80
|
||||
|
||||
return '{schema}://{auth}{hostname}{port}/{targets}' \
|
||||
.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([NotifyNextcloudTalk.quote(x)
|
||||
for x in self.targets]),
|
||||
)
|
||||
|
||||
@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
|
||||
|
||||
# Fetch our targets
|
||||
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.")
|
||||
|
||||
return results
|
678
libs/apprise/plugins/NotifyNtfy.py
Normal file
678
libs/apprise/plugins/NotifyNtfy.py
Normal file
|
@ -0,0 +1,678 @@
|
|||
# MIT License
|
||||
|
||||
# Copyright (c) 2022 Joey Espinosa <@particledecay>
|
||||
|
||||
# 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.
|
||||
#
|
||||
# Examples:
|
||||
# ntfys://my-topic
|
||||
# ntfy://ntfy.local.domain/my-topic
|
||||
# ntfys://ntfy.local.domain:8080/my-topic
|
||||
# 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
|
||||
|
||||
from .NotifyBase import NotifyBase
|
||||
from ..common import NotifyType
|
||||
from ..AppriseLocale import gettext_lazy as _
|
||||
from ..utils import parse_list
|
||||
from ..utils import is_hostname
|
||||
from ..utils import is_ipaddr
|
||||
from ..utils import validate_regex
|
||||
from ..URLBase import PrivacyMode
|
||||
from ..attachment.AttachBase import AttachBase
|
||||
|
||||
|
||||
class NtfyMode(object):
|
||||
"""
|
||||
Define ntfy Notification Modes
|
||||
"""
|
||||
# App posts upstream to the developer API on ntfy's website
|
||||
CLOUD = "cloud"
|
||||
|
||||
# Running a dedicated private ntfy Server
|
||||
PRIVATE = "private"
|
||||
|
||||
|
||||
NTFY_MODES = (
|
||||
NtfyMode.CLOUD,
|
||||
NtfyMode.PRIVATE,
|
||||
)
|
||||
|
||||
|
||||
class NtfyPriority(object):
|
||||
"""
|
||||
Ntfy Priority Definitions
|
||||
"""
|
||||
MAX = 'max'
|
||||
HIGH = 'high'
|
||||
NORMAL = 'default'
|
||||
LOW = 'low'
|
||||
MIN = 'min'
|
||||
|
||||
|
||||
NTFY_PRIORITIES = (
|
||||
NtfyPriority.MAX,
|
||||
NtfyPriority.HIGH,
|
||||
NtfyPriority.NORMAL,
|
||||
NtfyPriority.LOW,
|
||||
NtfyPriority.MIN,
|
||||
)
|
||||
|
||||
|
||||
class NotifyNtfy(NotifyBase):
|
||||
"""
|
||||
A wrapper for ntfy Notifications
|
||||
"""
|
||||
|
||||
# The default descriptive name associated with the Notification
|
||||
service_name = 'ntfy'
|
||||
|
||||
# The services URL
|
||||
service_url = 'https://ntfy.sh/'
|
||||
|
||||
# Insecure protocol (for those self hosted requests)
|
||||
protocol = 'ntfy'
|
||||
|
||||
# The default protocol
|
||||
secure_protocol = 'ntfys'
|
||||
|
||||
# A URL that takes you to the setup/help of the specific protocol
|
||||
setup_url = 'https://github.com/caronc/apprise/wiki/Notify_ntfy'
|
||||
|
||||
# Default upstream/cloud host if none is defined
|
||||
cloud_notify_url = 'https://ntfy.sh'
|
||||
|
||||
# Message time to live (if remote client isn't around to receive it)
|
||||
time_to_live = 2419200
|
||||
|
||||
# if our hostname matches the following we automatically enforce
|
||||
# cloud mode
|
||||
__auto_cloud_host = re.compile(r'ntfy\.sh', re.IGNORECASE)
|
||||
|
||||
# Define object templates
|
||||
templates = (
|
||||
'{schema}://{topic}',
|
||||
'{schema}://{host}/{targets}',
|
||||
'{schema}://{host}:{port}/{targets}',
|
||||
'{schema}://{user}@{host}/{targets}',
|
||||
'{schema}://{user}@{host}:{port}/{targets}',
|
||||
'{schema}://{user}:{password}@{host}/{targets}',
|
||||
'{schema}://{user}:{password}@{host}:{port}/{targets}',
|
||||
)
|
||||
|
||||
# Define our template tokens
|
||||
template_tokens = dict(NotifyBase.template_tokens, **{
|
||||
'host': {
|
||||
'name': _('Hostname'),
|
||||
'type': 'string',
|
||||
},
|
||||
'port': {
|
||||
'name': _('Port'),
|
||||
'type': 'int',
|
||||
'min': 1,
|
||||
'max': 65535,
|
||||
},
|
||||
'user': {
|
||||
'name': _('Username'),
|
||||
'type': 'string',
|
||||
},
|
||||
'password': {
|
||||
'name': _('Password'),
|
||||
'type': 'string',
|
||||
'private': True,
|
||||
},
|
||||
'topic': {
|
||||
'name': _('Topic'),
|
||||
'type': 'string',
|
||||
'map_to': 'targets',
|
||||
'regex': (r'^[a-z0-9_-]{1,64}$', 'i')
|
||||
},
|
||||
'targets': {
|
||||
'name': _('Targets'),
|
||||
'type': 'list:string',
|
||||
},
|
||||
})
|
||||
|
||||
# Define our template arguments
|
||||
template_args = dict(NotifyBase.template_args, **{
|
||||
'attach': {
|
||||
'name': _('Attach'),
|
||||
'type': 'string',
|
||||
},
|
||||
'filename': {
|
||||
'name': _('Attach Filename'),
|
||||
'type': 'string',
|
||||
},
|
||||
'click': {
|
||||
'name': _('Click'),
|
||||
'type': 'string',
|
||||
},
|
||||
'delay': {
|
||||
'name': _('Delay'),
|
||||
'type': 'string',
|
||||
},
|
||||
'email': {
|
||||
'name': _('Email'),
|
||||
'type': 'string',
|
||||
},
|
||||
'priority': {
|
||||
'name': _('Priority'),
|
||||
'type': 'choice:string',
|
||||
'values': NTFY_PRIORITIES,
|
||||
'default': NtfyPriority.NORMAL,
|
||||
},
|
||||
'tags': {
|
||||
'name': _('Tags'),
|
||||
'type': 'string',
|
||||
},
|
||||
'mode': {
|
||||
'name': _('Mode'),
|
||||
'type': 'choice:string',
|
||||
'values': NTFY_MODES,
|
||||
'default': NtfyMode.PRIVATE,
|
||||
},
|
||||
'to': {
|
||||
'alias_of': 'targets',
|
||||
},
|
||||
})
|
||||
|
||||
def __init__(self, targets=None, attach=None, filename=None, click=None,
|
||||
delay=None, email=None, priority=None, tags=None, mode=None,
|
||||
**kwargs):
|
||||
"""
|
||||
Initialize ntfy Object
|
||||
"""
|
||||
super(NotifyNtfy, self).__init__(**kwargs)
|
||||
|
||||
# Prepare our mode
|
||||
self.mode = mode.strip().lower() \
|
||||
if isinstance(mode, six.string_types) \
|
||||
else self.template_args['mode']['default']
|
||||
|
||||
if self.mode not in NTFY_MODES:
|
||||
msg = 'An invalid ntfy Mode ({}) was specified.'.format(mode)
|
||||
self.logger.warning(msg)
|
||||
raise TypeError(msg)
|
||||
|
||||
# Attach a file (URL supported)
|
||||
self.attach = attach
|
||||
|
||||
# Our filename (if defined)
|
||||
self.filename = filename
|
||||
|
||||
# A clickthrough option for notifications
|
||||
self.click = click
|
||||
|
||||
# Time delay for notifications (various string formats)
|
||||
self.delay = delay
|
||||
|
||||
# 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)
|
||||
|
||||
# Any optional tags to attach to the notification
|
||||
self.__tags = parse_list(tags)
|
||||
|
||||
# Build list of topics
|
||||
topics = parse_list(targets)
|
||||
self.topics = []
|
||||
for _topic in topics:
|
||||
topic = validate_regex(
|
||||
_topic, *self.template_tokens['topic']['regex'])
|
||||
if not topic:
|
||||
self.logger.warning(
|
||||
'A specified ntfy topic ({}) is invalid and will be '
|
||||
'ignored'.format(_topic))
|
||||
continue
|
||||
self.topics.append(topic)
|
||||
return
|
||||
|
||||
def send(self, body, title='', notify_type=NotifyType.INFO, attach=None,
|
||||
**kwargs):
|
||||
"""
|
||||
Perform ntfy Notification
|
||||
"""
|
||||
|
||||
# error tracking (used for function return)
|
||||
has_error = False
|
||||
|
||||
if not len(self.topics):
|
||||
# We have nothing to notify; we're done
|
||||
self.logger.warning('There are no ntfy topics to notify')
|
||||
return False
|
||||
|
||||
# Create a copy of the subreddits list
|
||||
topics = list(self.topics)
|
||||
while len(topics) > 0:
|
||||
# Retrieve our topic
|
||||
topic = topics.pop()
|
||||
|
||||
if attach:
|
||||
# We need to upload our payload first so that we can source it
|
||||
# in remaining messages
|
||||
for no, attachment in enumerate(attach):
|
||||
|
||||
# First message only includes the text
|
||||
_body = body if not no else None
|
||||
_title = title if not no else None
|
||||
|
||||
# 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
|
||||
|
||||
self.logger.debug(
|
||||
'Preparing ntfy attachment {}'.format(
|
||||
attachment.url(privacy=True)))
|
||||
|
||||
okay, response = self._send(
|
||||
topic, body=_body, title=_title, attach=attachment)
|
||||
if not okay:
|
||||
# We can't post our attachment; abort immediately
|
||||
return False
|
||||
else:
|
||||
# Send our Notification Message
|
||||
okay, response = self._send(topic, body=body, title=title)
|
||||
if not okay:
|
||||
# Mark our failure, but contiue to move on
|
||||
has_error = True
|
||||
|
||||
return not has_error
|
||||
|
||||
def _send(self, topic, body=None, title=None, attach=None, **kwargs):
|
||||
"""
|
||||
Wrapper to the requests (post) object
|
||||
"""
|
||||
|
||||
# Prepare our headers
|
||||
headers = {
|
||||
'User-Agent': self.app_id,
|
||||
}
|
||||
|
||||
# Some default values for our request object to which we'll update
|
||||
# depending on what our payload is
|
||||
files = None
|
||||
|
||||
# See https://ntfy.sh/docs/publish/#publish-as-json
|
||||
data = {}
|
||||
|
||||
# Posting Parameters
|
||||
params = {}
|
||||
|
||||
auth = None
|
||||
if self.mode == NtfyMode.CLOUD:
|
||||
# Cloud Service
|
||||
notify_url = self.cloud_notify_url
|
||||
|
||||
else: # NotifyNtfy.PRVATE
|
||||
# Allow more settings to be applied now
|
||||
if self.user:
|
||||
auth = (self.user, self.password)
|
||||
|
||||
# Prepare our ntfy Template URL
|
||||
schema = 'https' if self.secure else 'http'
|
||||
|
||||
notify_url = '%s://%s' % (schema, self.host)
|
||||
if isinstance(self.port, int):
|
||||
notify_url += ':%d' % self.port
|
||||
|
||||
if not attach:
|
||||
headers['Content-Type'] = 'application/json'
|
||||
|
||||
data['topic'] = topic
|
||||
virt_payload = data
|
||||
|
||||
else:
|
||||
# Point our payload to our parameters
|
||||
virt_payload = params
|
||||
notify_url += '/{topic}'.format(topic=topic)
|
||||
|
||||
if title:
|
||||
virt_payload['title'] = title
|
||||
|
||||
if body:
|
||||
virt_payload['message'] = body
|
||||
|
||||
if self.priority != NtfyPriority.NORMAL:
|
||||
headers['X-Priority'] = self.priority
|
||||
|
||||
if self.delay is not None:
|
||||
headers['X-Delay'] = self.delay
|
||||
|
||||
if self.click is not None:
|
||||
headers['X-Click'] = self.click
|
||||
|
||||
if self.email is not None:
|
||||
headers['X-Email'] = self.email
|
||||
|
||||
if self.__tags:
|
||||
headers['X-Tags'] = ",".join(self.__tags)
|
||||
|
||||
if isinstance(attach, AttachBase):
|
||||
# Prepare our Header
|
||||
params['filename'] = attach.name
|
||||
|
||||
# prepare our files object
|
||||
files = {'file': (attach.name, open(attach.path, 'rb'))}
|
||||
|
||||
elif self.attach is not None:
|
||||
data['attach'] = self.attach
|
||||
if self.filename is not None:
|
||||
data['filename'] = self.filename
|
||||
|
||||
self.logger.debug('ntfy POST URL: %s (cert_verify=%r)' % (
|
||||
notify_url, self.verify_certificate,
|
||||
))
|
||||
self.logger.debug('ntfy Payload: %s' % str(virt_payload))
|
||||
self.logger.debug('ntfy Headers: %s' % str(headers))
|
||||
|
||||
# Always call throttle before any remote server i/o is made
|
||||
self.throttle()
|
||||
|
||||
# Default response type
|
||||
response = None
|
||||
|
||||
try:
|
||||
r = requests.post(
|
||||
notify_url,
|
||||
params=params if params else None,
|
||||
data=dumps(data) if data else None,
|
||||
headers=headers,
|
||||
files=files,
|
||||
auth=auth,
|
||||
verify=self.verify_certificate,
|
||||
timeout=self.request_timeout,
|
||||
)
|
||||
|
||||
if r.status_code != 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
|
||||
|
||||
try:
|
||||
# Update our status response if we can
|
||||
response = loads(r.content)
|
||||
status_str = response.get('error', status_str)
|
||||
status_code = \
|
||||
int(response.get('code', status_code))
|
||||
|
||||
except (AttributeError, TypeError, ValueError):
|
||||
# ValueError = r.content is Unparsable
|
||||
# TypeError = r.content is None
|
||||
# AttributeError = r is None
|
||||
|
||||
# We could not parse JSON response.
|
||||
# We will just use the status we already have.
|
||||
pass
|
||||
|
||||
self.logger.warning(
|
||||
"Failed to send ntfy notification to topic '{}': "
|
||||
'{}{}error={}.'.format(
|
||||
topic,
|
||||
status_str,
|
||||
', ' if status_str else '',
|
||||
status_code))
|
||||
|
||||
self.logger.debug(
|
||||
'Response Details:\r\n{}'.format(r.content))
|
||||
|
||||
return False, response
|
||||
|
||||
# otherwise we were successful
|
||||
self.logger.info(
|
||||
"Sent ntfy notification to '{}'.".format(notify_url))
|
||||
|
||||
return True, response
|
||||
|
||||
except requests.RequestException as e:
|
||||
self.logger.warning(
|
||||
'A Connection error occurred sending ntfy:%s ' % (
|
||||
notify_url) + 'notification.'
|
||||
)
|
||||
self.logger.debug('Socket Exception: %s' % str(e))
|
||||
return False, response
|
||||
|
||||
except (OSError, IOError) as e:
|
||||
self.logger.warning(
|
||||
'An I/O error occurred while handling {}.'.format(
|
||||
attach.name if isinstance(attach, AttachBase)
|
||||
else virt_payload))
|
||||
self.logger.debug('I/O Exception: %s' % str(e))
|
||||
return False, response
|
||||
|
||||
finally:
|
||||
# Close our file (if it's open) stored in the second element
|
||||
# of our files tuple (index 1)
|
||||
if files:
|
||||
files['file'][1].close()
|
||||
|
||||
def url(self, privacy=False, *args, **kwargs):
|
||||
"""
|
||||
Returns the URL built dynamically based on specified arguments.
|
||||
"""
|
||||
|
||||
default_port = 443 if self.secure else 80
|
||||
|
||||
params = {
|
||||
'priority': self.priority,
|
||||
'mode': self.mode,
|
||||
}
|
||||
|
||||
if self.attach is not None:
|
||||
params['attach'] = self.attach
|
||||
|
||||
if self.click is not None:
|
||||
params['click'] = self.click
|
||||
|
||||
if self.delay is not None:
|
||||
params['delay'] = self.delay
|
||||
|
||||
if self.email is not None:
|
||||
params['email'] = self.email
|
||||
|
||||
if self.__tags:
|
||||
params['tags'] = ','.join(self.__tags)
|
||||
|
||||
params.update(self.url_parameters(privacy=privacy, *args, **kwargs))
|
||||
|
||||
# Determine Authentication
|
||||
auth = ''
|
||||
if self.user and self.password:
|
||||
auth = '{user}:{password}@'.format(
|
||||
user=NotifyNtfy.quote(self.user, safe=''),
|
||||
password=self.pprint(
|
||||
self.password, privacy, mode=PrivacyMode.Secret, safe=''),
|
||||
)
|
||||
elif self.user:
|
||||
auth = '{user}@'.format(
|
||||
user=NotifyNtfy.quote(self.user, safe=''),
|
||||
)
|
||||
|
||||
if self.mode == NtfyMode.PRIVATE:
|
||||
return '{schema}://{auth}{host}{port}/{targets}?{params}'.format(
|
||||
schema=self.secure_protocol if self.secure else self.protocol,
|
||||
auth=auth,
|
||||
host=self.host,
|
||||
port='' if self.port is None or self.port == default_port
|
||||
else ':{}'.format(self.port),
|
||||
targets='/'.join(
|
||||
[NotifyNtfy.quote(x, safe='') for x in self.topics]),
|
||||
params=NotifyNtfy.urlencode(params)
|
||||
)
|
||||
|
||||
else: # Cloud mode
|
||||
return '{schema}://{targets}?{params}'.format(
|
||||
schema=self.secure_protocol,
|
||||
targets='/'.join(
|
||||
[NotifyNtfy.quote(x, safe='') for x in self.topics]),
|
||||
params=NotifyNtfy.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
|
||||
|
||||
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
|
||||
|
||||
if 'attach' in results['qsd'] and len(results['qsd']['attach']):
|
||||
results['attach'] = NotifyNtfy.unquote(results['qsd']['attach'])
|
||||
_results = NotifyBase.parse_url(results['attach'])
|
||||
if _results:
|
||||
results['filename'] = \
|
||||
None if _results['fullpath'] \
|
||||
else basename(_results['fullpath'])
|
||||
|
||||
if 'filename' in results['qsd'] and \
|
||||
len(results['qsd']['filename']):
|
||||
results['filename'] = \
|
||||
basename(NotifyNtfy.unquote(results['qsd']['filename']))
|
||||
|
||||
if 'click' in results['qsd'] and len(results['qsd']['click']):
|
||||
results['click'] = NotifyNtfy.unquote(results['qsd']['click'])
|
||||
|
||||
if 'delay' in results['qsd'] and len(results['qsd']['delay']):
|
||||
results['delay'] = NotifyNtfy.unquote(results['qsd']['delay'])
|
||||
|
||||
if 'email' in results['qsd'] and len(results['qsd']['email']):
|
||||
results['email'] = NotifyNtfy.unquote(results['qsd']['email'])
|
||||
|
||||
if 'tags' in results['qsd'] and len(results['qsd']['tags']):
|
||||
results['tags'] = \
|
||||
parse_list(NotifyNtfy.unquote(results['qsd']['tags']))
|
||||
|
||||
# Acquire our targets/topics
|
||||
results['targets'] = NotifyNtfy.split_path(results['fullpath'])
|
||||
|
||||
# The 'to' makes it easier to use yaml configuration
|
||||
if 'to' in results['qsd'] and len(results['qsd']['to']):
|
||||
results['targets'] += \
|
||||
NotifyNtfy.parse_list(results['qsd']['to'])
|
||||
|
||||
# Mode override
|
||||
if 'mode' in results['qsd'] and results['qsd']['mode']:
|
||||
results['mode'] = NotifyNtfy.unquote(
|
||||
results['qsd']['mode'].strip().lower())
|
||||
|
||||
else:
|
||||
# We can try to detect the mode based on the validity of the
|
||||
# hostname.
|
||||
#
|
||||
# This isn't a surfire way to do things though; it's best to
|
||||
# specify the mode= flag
|
||||
results['mode'] = NtfyMode.PRIVATE \
|
||||
if ((is_hostname(results['host'])
|
||||
or is_ipaddr(results['host'])) and results['targets']) \
|
||||
else NtfyMode.CLOUD
|
||||
|
||||
if results['mode'] == NtfyMode.CLOUD:
|
||||
# Store first entry as it can be a topic too in this case
|
||||
# But only if we also rule it out not being the words
|
||||
# ntfy.sh itself, something that starts wiht an non-alpha numeric
|
||||
# character:
|
||||
if not NotifyNtfy.__auto_cloud_host.search(results['host']):
|
||||
# Add it to the front of the list for consistency
|
||||
results['targets'].insert(0, results['host'])
|
||||
|
||||
elif results['mode'] == NtfyMode.PRIVATE and \
|
||||
not (is_hostname(results['host'] or
|
||||
is_ipaddr(results['host']))):
|
||||
# Invalid Host for NtfyMode.PRIVATE
|
||||
return None
|
||||
|
||||
return results
|
||||
|
||||
@staticmethod
|
||||
def parse_native_url(url):
|
||||
"""
|
||||
Support https://ntfy.sh/topic
|
||||
"""
|
||||
|
||||
# Quick lookup for users who want to just paste
|
||||
# the ntfy.sh url directly into Apprise
|
||||
result = re.match(
|
||||
r'^(http|ntfy)s?://ntfy\.sh'
|
||||
r'(?P<topics>/[^?]+)?'
|
||||
r'(?P<params>\?.+)?$', url, re.I)
|
||||
|
||||
if result:
|
||||
mode = 'mode=%s' % NtfyMode.CLOUD
|
||||
return NotifyNtfy.parse_url(
|
||||
'{schema}://{topics}{params}'.format(
|
||||
schema=NotifyNtfy.secure_protocol,
|
||||
topics=result.group('topics')
|
||||
if result.group('topics') else '',
|
||||
params='?%s' % mode
|
||||
if not result.group('params')
|
||||
else result.group('params') + '&%s' % mode))
|
||||
|
||||
return None
|
|
@ -161,14 +161,14 @@ class NotifyReddit(NotifyBase):
|
|||
'type': 'string',
|
||||
'private': True,
|
||||
'required': True,
|
||||
'regex': (r'^[a-z0-9-]+$', 'i'),
|
||||
'regex': (r'^[a-z0-9_-]+$', 'i'),
|
||||
},
|
||||
'app_secret': {
|
||||
'name': _('Application Secret'),
|
||||
'type': 'string',
|
||||
'private': True,
|
||||
'required': True,
|
||||
'regex': (r'^[a-z0-9-]+$', 'i'),
|
||||
'regex': (r'^[a-z0-9_-]+$', 'i'),
|
||||
},
|
||||
'target_subreddit': {
|
||||
'name': _('Target Subreddit'),
|
||||
|
@ -465,7 +465,7 @@ class NotifyReddit(NotifyBase):
|
|||
'api_type': 'json',
|
||||
'extension': 'json',
|
||||
'sr': subreddit,
|
||||
'title': title,
|
||||
'title': title if title else self.app_desc,
|
||||
'kind': kind,
|
||||
'nsfw': True if self.nsfw else False,
|
||||
'resubmit': True if self.resubmit else False,
|
||||
|
|
950
libs/apprise/plugins/NotifySES.py
Normal file
950
libs/apprise/plugins/NotifySES.py
Normal file
|
@ -0,0 +1,950 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
#
|
||||
# Copyright (C) 2021 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 Information:
|
||||
# - https://docs.aws.amazon.com/ses/latest/APIReference/API_SendRawEmail.html
|
||||
#
|
||||
# AWS Credentials (access_key and secret_access_key)
|
||||
# - https://docs.aws.amazon.com/sdk-for-java/v1/developer-guide/\
|
||||
# setup-credentials.html
|
||||
# - https://docs.aws.amazon.com/toolkit-for-eclipse/v1/user-guide/\
|
||||
# setup-credentials.html
|
||||
#
|
||||
# Other systems write these credentials to:
|
||||
# - ~/.aws/credentials on Linux, macOS, or Unix
|
||||
# - C:\Users\USERNAME\.aws\credentials on Windows
|
||||
#
|
||||
#
|
||||
# To get A users access key ID and secret access key
|
||||
#
|
||||
# 1. Open the IAM console: https://console.aws.amazon.com/iam/home
|
||||
# 2. On the navigation menu, choose Users.
|
||||
# 3. Choose your IAM user name (not the check box).
|
||||
# 4. Open the Security credentials tab, and then choose:
|
||||
# Create Access key - Programmatic access
|
||||
# 5. To see the new access key, choose Show. Your credentials resemble
|
||||
# the following:
|
||||
# Access key ID: AKIAIOSFODNN7EXAMPLE
|
||||
# Secret access key: wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY
|
||||
#
|
||||
# To download the key pair, choose Download .csv file. Store the keys
|
||||
# The account requries this permssion to 'SES v2 : SendEmail' in order to
|
||||
# work
|
||||
#
|
||||
# To get the root users account (if you're logged in as that) you can
|
||||
# visit: https://console.aws.amazon.com/iam/home#/\
|
||||
# security_credentials$access_key
|
||||
#
|
||||
# This information is vital to work with SES
|
||||
|
||||
|
||||
# To use/test the service, i logged into the portal via:
|
||||
# - https://portal.aws.amazon.com
|
||||
#
|
||||
# Go to the dashboard of the Amazon SES (Simple Email Service)
|
||||
# 1. You must have a verified identity; click on that option and create one
|
||||
# if you don't already have one. Until it's verified, you won't be able to
|
||||
# do the next step.
|
||||
# 2. From here you'll be able to retrieve your ARN associated with your
|
||||
# identity you want Apprise to send emails on behalf. It might look
|
||||
# something like:
|
||||
# arn:aws:ses:us-east-2:133216123003:identity/user@example.com
|
||||
#
|
||||
# This is your ARN (Amazon Record Name)
|
||||
#
|
||||
#
|
||||
|
||||
import re
|
||||
import hmac
|
||||
import base64
|
||||
import requests
|
||||
from hashlib import sha256
|
||||
from datetime import datetime
|
||||
from collections import OrderedDict
|
||||
from xml.etree import ElementTree
|
||||
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.header import Header
|
||||
try:
|
||||
# Python v3.x
|
||||
from urllib.parse import quote
|
||||
|
||||
except ImportError:
|
||||
# Python v2.x
|
||||
from urllib import quote
|
||||
|
||||
from .NotifyBase import NotifyBase
|
||||
from ..URLBase import PrivacyMode
|
||||
from ..common import NotifyFormat
|
||||
from ..common import NotifyType
|
||||
from ..utils import parse_emails
|
||||
from ..utils import validate_regex
|
||||
from ..AppriseLocale import gettext_lazy as _
|
||||
from ..utils import is_email
|
||||
|
||||
# Our Regin Identifier
|
||||
# support us-gov-west-1 syntax as well
|
||||
IS_REGION = re.compile(
|
||||
r'^\s*(?P<country>[a-z]{2})-(?P<area>[a-z-]+?)-(?P<no>[0-9]+)\s*$', re.I)
|
||||
|
||||
# Extend HTTP Error Messages
|
||||
AWS_HTTP_ERROR_MAP = {
|
||||
403: 'Unauthorized - Invalid Access/Secret Key Combination.',
|
||||
}
|
||||
|
||||
|
||||
class NotifySES(NotifyBase):
|
||||
"""
|
||||
A wrapper for AWS SES (Amazon Simple Email Service)
|
||||
"""
|
||||
|
||||
# The default descriptive name associated with the Notification
|
||||
service_name = 'AWS Simple Email Service (SES)'
|
||||
|
||||
# The services URL
|
||||
service_url = 'https://aws.amazon.com/ses/'
|
||||
|
||||
# The default secure protocol
|
||||
secure_protocol = 'ses'
|
||||
|
||||
# A URL that takes you to the setup/help of the specific protocol
|
||||
setup_url = 'https://github.com/caronc/apprise/wiki/Notify_ses'
|
||||
|
||||
# AWS is pretty good for handling data load so request limits
|
||||
# can occur in much shorter bursts
|
||||
request_rate_per_sec = 2.5
|
||||
|
||||
# Default Notify Format
|
||||
notify_format = NotifyFormat.HTML
|
||||
|
||||
# Define object templates
|
||||
templates = (
|
||||
'{schema}://{from_email}/{access_key_id}/{secret_access_key}/'
|
||||
'{region}/{targets}',
|
||||
'{schema}://{from_email}/{access_key_id}/{secret_access_key}/'
|
||||
'{region}',
|
||||
)
|
||||
|
||||
# Define our template tokens
|
||||
template_tokens = dict(NotifyBase.template_tokens, **{
|
||||
'from_email': {
|
||||
'name': _('From Email'),
|
||||
'type': 'string',
|
||||
'map_to': 'from_addr',
|
||||
},
|
||||
'access_key_id': {
|
||||
'name': _('Access Key ID'),
|
||||
'type': 'string',
|
||||
'private': True,
|
||||
'required': True,
|
||||
},
|
||||
'secret_access_key': {
|
||||
'name': _('Secret Access Key'),
|
||||
'type': 'string',
|
||||
'private': True,
|
||||
'required': True,
|
||||
},
|
||||
'region': {
|
||||
'name': _('Region'),
|
||||
'type': 'string',
|
||||
'regex': (r'^[a-z]{2}-[a-z-]+?-[0-9]+$', 'i'),
|
||||
'map_to': 'region_name',
|
||||
},
|
||||
'targets': {
|
||||
'name': _('Target Emails'),
|
||||
'type': 'list:string',
|
||||
},
|
||||
})
|
||||
|
||||
# Define our template arguments
|
||||
template_args = dict(NotifyBase.template_args, **{
|
||||
'to': {
|
||||
'alias_of': 'targets',
|
||||
},
|
||||
'from': {
|
||||
'alias_of': 'from_email',
|
||||
},
|
||||
'reply': {
|
||||
'name': _('Reply To Email'),
|
||||
'type': 'string',
|
||||
'map_to': 'reply_to',
|
||||
},
|
||||
'name': {
|
||||
'name': _('From Name'),
|
||||
'type': 'string',
|
||||
'map_to': 'from_name',
|
||||
},
|
||||
'cc': {
|
||||
'name': _('Carbon Copy'),
|
||||
'type': 'list:string',
|
||||
},
|
||||
'bcc': {
|
||||
'name': _('Blind Carbon Copy'),
|
||||
'type': 'list:string',
|
||||
},
|
||||
'access': {
|
||||
'alias_of': 'access_key_id',
|
||||
},
|
||||
'secret': {
|
||||
'alias_of': 'secret_access_key',
|
||||
},
|
||||
'region': {
|
||||
'alias_of': 'region',
|
||||
},
|
||||
})
|
||||
|
||||
def __init__(self, access_key_id, secret_access_key, region_name,
|
||||
reply_to=None, from_addr=None, from_name=None, targets=None,
|
||||
cc=None, bcc=None, **kwargs):
|
||||
"""
|
||||
Initialize Notify AWS SES Object
|
||||
"""
|
||||
super(NotifySES, self).__init__(**kwargs)
|
||||
|
||||
# Store our AWS API Access Key
|
||||
self.aws_access_key_id = validate_regex(access_key_id)
|
||||
if not self.aws_access_key_id:
|
||||
msg = 'An invalid AWS Access Key ID was specified.'
|
||||
self.logger.warning(msg)
|
||||
raise TypeError(msg)
|
||||
|
||||
# Store our AWS API Secret Access key
|
||||
self.aws_secret_access_key = validate_regex(secret_access_key)
|
||||
if not self.aws_secret_access_key:
|
||||
msg = 'An invalid AWS Secret Access Key ' \
|
||||
'({}) was specified.'.format(secret_access_key)
|
||||
self.logger.warning(msg)
|
||||
raise TypeError(msg)
|
||||
|
||||
# Acquire our AWS Region Name:
|
||||
# eg. us-east-1, cn-north-1, us-west-2, ...
|
||||
self.aws_region_name = validate_regex(
|
||||
region_name, *self.template_tokens['region']['regex'])
|
||||
if not self.aws_region_name:
|
||||
msg = 'An invalid AWS Region ({}) was specified.'.format(
|
||||
region_name)
|
||||
self.logger.warning(msg)
|
||||
raise TypeError(msg)
|
||||
|
||||
# Acquire Email 'To'
|
||||
self.targets = list()
|
||||
|
||||
# Acquire Carbon Copies
|
||||
self.cc = set()
|
||||
|
||||
# Acquire Blind Carbon Copies
|
||||
self.bcc = set()
|
||||
|
||||
# For tracking our email -> name lookups
|
||||
self.names = {}
|
||||
|
||||
# Set our notify_url based on our region
|
||||
self.notify_url = 'https://email.{}.amazonaws.com'\
|
||||
.format(self.aws_region_name)
|
||||
|
||||
# AWS Service Details
|
||||
self.aws_service_name = 'ses'
|
||||
self.aws_canonical_uri = '/'
|
||||
|
||||
# AWS Authentication Details
|
||||
self.aws_auth_version = 'AWS4'
|
||||
self.aws_auth_algorithm = 'AWS4-HMAC-SHA256'
|
||||
self.aws_auth_request = 'aws4_request'
|
||||
|
||||
# Get our From username (if specified)
|
||||
self.from_name = from_name
|
||||
|
||||
if from_addr:
|
||||
self.from_addr = from_addr
|
||||
|
||||
else:
|
||||
# Get our from email address
|
||||
self.from_addr = '{user}@{host}'.format(
|
||||
user=self.user, host=self.host) if self.user else None
|
||||
|
||||
if not (self.from_addr and is_email(self.from_addr)):
|
||||
msg = 'An invalid AWS From ({}) was specified.'.format(
|
||||
'{user}@{host}'.format(user=self.user, host=self.host))
|
||||
self.logger.warning(msg)
|
||||
raise TypeError(msg)
|
||||
|
||||
self.reply_to = None
|
||||
if reply_to:
|
||||
result = is_email(reply_to)
|
||||
if not result:
|
||||
msg = 'An invalid AWS Reply To ({}) was specified.'.format(
|
||||
'{user}@{host}'.format(user=self.user, host=self.host))
|
||||
self.logger.warning(msg)
|
||||
raise TypeError(msg)
|
||||
|
||||
self.reply_to = (
|
||||
result['name'] if result['name'] else False,
|
||||
result['full_email'])
|
||||
|
||||
if targets:
|
||||
# Validate recipients (to:) and drop bad ones:
|
||||
for recipient in parse_emails(targets):
|
||||
result = is_email(recipient)
|
||||
if result:
|
||||
self.targets.append(
|
||||
(result['name'] if result['name'] else False,
|
||||
result['full_email']))
|
||||
continue
|
||||
|
||||
self.logger.warning(
|
||||
'Dropped invalid To email '
|
||||
'({}) specified.'.format(recipient),
|
||||
)
|
||||
|
||||
else:
|
||||
# If our target email list is empty we want to add ourselves to it
|
||||
self.targets.append(
|
||||
(self.from_name if self.from_name else False, self.from_addr))
|
||||
|
||||
# Validate recipients (cc:) and drop bad ones:
|
||||
for recipient in parse_emails(cc):
|
||||
email = is_email(recipient)
|
||||
if email:
|
||||
self.cc.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 Carbon Copy email '
|
||||
'({}) specified.'.format(recipient),
|
||||
)
|
||||
|
||||
# Validate recipients (bcc:) and drop bad ones:
|
||||
for recipient in parse_emails(bcc):
|
||||
email = is_email(recipient)
|
||||
if email:
|
||||
self.bcc.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 Blind Carbon Copy email '
|
||||
'({}) specified.'.format(recipient),
|
||||
)
|
||||
|
||||
return
|
||||
|
||||
def send(self, body, title='', notify_type=NotifyType.INFO, attach=None,
|
||||
**kwargs):
|
||||
"""
|
||||
wrapper to send_notification since we can alert more then one channel
|
||||
"""
|
||||
|
||||
if not self.targets:
|
||||
# There is no one to email; we're done
|
||||
self.logger.warning(
|
||||
'There are no SES email recipients to notify')
|
||||
return False
|
||||
|
||||
# error tracking (used for function return)
|
||||
has_error = False
|
||||
|
||||
# Initialize our default from name
|
||||
from_name = self.from_name if self.from_name \
|
||||
else self.reply_to[0] if self.reply_to and \
|
||||
self.reply_to[0] else self.app_desc
|
||||
|
||||
reply_to = (
|
||||
from_name, self.from_addr
|
||||
if not self.reply_to else self.reply_to[1])
|
||||
|
||||
# Create a copy of the targets list
|
||||
emails = list(self.targets)
|
||||
while len(emails):
|
||||
# Get our email to notify
|
||||
to_name, to_addr = emails.pop(0)
|
||||
|
||||
# Strip target out of cc list if in To or Bcc
|
||||
cc = (self.cc - self.bcc - set([to_addr]))
|
||||
|
||||
# Strip target out of bcc list if in To
|
||||
bcc = (self.bcc - set([to_addr]))
|
||||
|
||||
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 bcc addresses to support the Name field
|
||||
bcc = [formataddr(
|
||||
(self.names.get(addr, False), addr), charset='utf-8')
|
||||
for addr in bcc]
|
||||
|
||||
except TypeError:
|
||||
# Python v2.x Support (no charset keyword)
|
||||
# Format our cc addresses to support the Name field
|
||||
cc = [formataddr( # pragma: no branch
|
||||
(self.names.get(addr, False), addr)) for addr in cc]
|
||||
|
||||
# Format our bcc addresses to support the Name field
|
||||
bcc = [formataddr( # pragma: no branch
|
||||
(self.names.get(addr, False), addr)) for addr in bcc]
|
||||
|
||||
self.logger.debug('Email From: {} <{}>'.format(
|
||||
quote(reply_to[0], ' '),
|
||||
quote(reply_to[1], '@ ')))
|
||||
|
||||
self.logger.debug('Email To: {}'.format(to_addr))
|
||||
if cc:
|
||||
self.logger.debug('Email Cc: {}'.format(', '.join(cc)))
|
||||
if bcc:
|
||||
self.logger.debug('Email Bcc: {}'.format(', '.join(bcc)))
|
||||
|
||||
# Prepare Email Message
|
||||
if self.notify_format == NotifyFormat.HTML:
|
||||
content = MIMEText(body, 'html', 'utf-8')
|
||||
|
||||
else:
|
||||
content = MIMEText(body, 'plain', 'utf-8')
|
||||
|
||||
# Create a Multipart container if there is an attachment
|
||||
base = MIMEMultipart() if attach else content
|
||||
|
||||
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['Cc'] = ','.join(cc)
|
||||
base['Date'] = \
|
||||
datetime.utcnow().strftime("%a, %d %b %Y %H:%M:%S +0000")
|
||||
base['X-Application'] = self.app_id
|
||||
|
||||
if attach:
|
||||
# First attach our body to our content as the first element
|
||||
base.attach(content)
|
||||
|
||||
# Now store our attachments
|
||||
for attachment in attach:
|
||||
if not attachment:
|
||||
# We could not load the attachment; take an early
|
||||
# exit since this isn't what the end user wanted
|
||||
|
||||
# We could not access the attachment
|
||||
self.logger.error(
|
||||
'Could not access attachment {}.'.format(
|
||||
attachment.url(privacy=True)))
|
||||
|
||||
return False
|
||||
|
||||
self.logger.debug(
|
||||
'Preparing Email attachment {}'.format(
|
||||
attachment.url(privacy=True)))
|
||||
|
||||
with open(attachment.path, "rb") as abody:
|
||||
app = MIMEApplication(abody.read())
|
||||
app.set_type(attachment.mimetype)
|
||||
|
||||
app.add_header(
|
||||
'Content-Disposition',
|
||||
'attachment; filename="{}"'.format(
|
||||
Header(attachment.name, 'utf-8')),
|
||||
)
|
||||
|
||||
base.attach(app)
|
||||
|
||||
# Prepare our payload object
|
||||
payload = {
|
||||
'Action': 'SendRawEmail',
|
||||
'Version': '2010-12-01',
|
||||
'RawMessage.Data': base64.b64encode(
|
||||
base.as_string().encode('utf-8')).decode('utf-8')
|
||||
}
|
||||
|
||||
for no, email in enumerate(([to_addr] + bcc + cc), start=1):
|
||||
payload['Destinations.member.{}'.format(no)] = email
|
||||
|
||||
# Specify from address
|
||||
payload['Source'] = '{} <{}>'.format(
|
||||
quote(from_name, ' '),
|
||||
quote(self.from_addr, '@ '))
|
||||
|
||||
(result, response) = self._post(payload=payload, to=to_addr)
|
||||
if not result:
|
||||
# Mark our failure
|
||||
has_error = True
|
||||
continue
|
||||
|
||||
return not has_error
|
||||
|
||||
def _post(self, payload, to):
|
||||
"""
|
||||
Wrapper to request.post() to manage it's response better and make
|
||||
the send() function cleaner and easier to maintain.
|
||||
|
||||
This function returns True if the _post was successful and False
|
||||
if it wasn't.
|
||||
"""
|
||||
|
||||
# Always call throttle before any remote server i/o is made; for AWS
|
||||
# time plays a huge factor in the headers being sent with the payload.
|
||||
# So for AWS (SES) requests we must throttle before they're generated
|
||||
# and not directly before the i/o call like other notification
|
||||
# services do.
|
||||
self.throttle()
|
||||
|
||||
# Convert our payload from a dict() into a urlencoded string
|
||||
payload = NotifySES.urlencode(payload)
|
||||
|
||||
# Prepare our Notification URL
|
||||
# Prepare our AWS Headers based on our payload
|
||||
headers = self.aws_prepare_request(payload)
|
||||
|
||||
self.logger.debug('AWS SES POST URL: %s (cert_verify=%r)' % (
|
||||
self.notify_url, self.verify_certificate,
|
||||
))
|
||||
self.logger.debug('AWS SES Payload (%d bytes)', len(payload))
|
||||
|
||||
try:
|
||||
r = requests.post(
|
||||
self.notify_url,
|
||||
data=payload,
|
||||
headers=headers,
|
||||
verify=self.verify_certificate,
|
||||
timeout=self.request_timeout,
|
||||
)
|
||||
|
||||
if r.status_code != requests.codes.ok:
|
||||
# We had a problem
|
||||
status_str = \
|
||||
NotifySES.http_response_code_lookup(
|
||||
r.status_code, AWS_HTTP_ERROR_MAP)
|
||||
|
||||
self.logger.warning(
|
||||
'Failed to send AWS SES notification to {}: '
|
||||
'{}{}error={}.'.format(
|
||||
to,
|
||||
status_str,
|
||||
', ' if status_str else '',
|
||||
r.status_code))
|
||||
|
||||
self.logger.debug('Response Details:\r\n{}'.format(r.content))
|
||||
|
||||
return (False, NotifySES.aws_response_to_dict(r.text))
|
||||
|
||||
else:
|
||||
self.logger.info(
|
||||
'Sent AWS SES notification to "%s".' % (to))
|
||||
|
||||
except requests.RequestException as e:
|
||||
self.logger.warning(
|
||||
'A Connection error occurred sending AWS SES '
|
||||
'notification to "%s".' % (to),
|
||||
)
|
||||
self.logger.debug('Socket Exception: %s' % str(e))
|
||||
return (False, NotifySES.aws_response_to_dict(None))
|
||||
|
||||
return (True, NotifySES.aws_response_to_dict(r.text))
|
||||
|
||||
def aws_prepare_request(self, payload, reference=None):
|
||||
"""
|
||||
Takes the intended payload and returns the headers for it.
|
||||
|
||||
The payload is presumed to have been already urlencoded()
|
||||
|
||||
"""
|
||||
|
||||
# Define our AWS SES header
|
||||
headers = {
|
||||
'User-Agent': self.app_id,
|
||||
'Content-Type': 'application/x-www-form-urlencoded; charset=utf-8',
|
||||
|
||||
# Populated below
|
||||
'Content-Length': 0,
|
||||
'Authorization': None,
|
||||
'X-Amz-Date': None,
|
||||
}
|
||||
|
||||
# Get a reference time (used for header construction)
|
||||
reference = datetime.utcnow()
|
||||
|
||||
# Provide Content-Length
|
||||
headers['Content-Length'] = str(len(payload))
|
||||
|
||||
# Amazon Date Format
|
||||
amzdate = reference.strftime('%Y%m%dT%H%M%SZ')
|
||||
headers['X-Amz-Date'] = amzdate
|
||||
|
||||
# Credential Scope
|
||||
scope = '{date}/{region}/{service}/{request}'.format(
|
||||
date=reference.strftime('%Y%m%d'),
|
||||
region=self.aws_region_name,
|
||||
service=self.aws_service_name,
|
||||
request=self.aws_auth_request,
|
||||
)
|
||||
|
||||
# Similar to headers; but a subset. keys must be lowercase
|
||||
signed_headers = OrderedDict([
|
||||
('content-type', headers['Content-Type']),
|
||||
('host', 'email.{region}.amazonaws.com'.format(
|
||||
region=self.aws_region_name)),
|
||||
('x-amz-date', headers['X-Amz-Date']),
|
||||
])
|
||||
|
||||
#
|
||||
# Build Canonical Request Object
|
||||
#
|
||||
canonical_request = '\n'.join([
|
||||
# Method
|
||||
u'POST',
|
||||
|
||||
# URL
|
||||
self.aws_canonical_uri,
|
||||
|
||||
# Query String (none set for POST)
|
||||
'',
|
||||
|
||||
# Header Content (must include \n at end!)
|
||||
# All entries except characters in amazon date must be
|
||||
# lowercase
|
||||
'\n'.join(['%s:%s' % (k, v)
|
||||
for k, v in signed_headers.items()]) + '\n',
|
||||
|
||||
# Header Entries (in same order identified above)
|
||||
';'.join(signed_headers.keys()),
|
||||
|
||||
# Payload
|
||||
sha256(payload.encode('utf-8')).hexdigest(),
|
||||
])
|
||||
|
||||
# Prepare Unsigned Signature
|
||||
to_sign = '\n'.join([
|
||||
self.aws_auth_algorithm,
|
||||
amzdate,
|
||||
scope,
|
||||
sha256(canonical_request.encode('utf-8')).hexdigest(),
|
||||
])
|
||||
|
||||
# Our Authorization header
|
||||
headers['Authorization'] = ', '.join([
|
||||
'{algorithm} Credential={key}/{scope}'.format(
|
||||
algorithm=self.aws_auth_algorithm,
|
||||
key=self.aws_access_key_id,
|
||||
scope=scope,
|
||||
),
|
||||
'SignedHeaders={signed_headers}'.format(
|
||||
signed_headers=';'.join(signed_headers.keys()),
|
||||
),
|
||||
'Signature={signature}'.format(
|
||||
signature=self.aws_auth_signature(to_sign, reference)
|
||||
),
|
||||
])
|
||||
|
||||
return headers
|
||||
|
||||
def aws_auth_signature(self, to_sign, reference):
|
||||
"""
|
||||
Generates a AWS v4 signature based on provided payload
|
||||
which should be in the form of a string.
|
||||
"""
|
||||
|
||||
def _sign(key, msg, to_hex=False):
|
||||
"""
|
||||
Perform AWS Signing
|
||||
"""
|
||||
if to_hex:
|
||||
return hmac.new(key, msg.encode('utf-8'), sha256).hexdigest()
|
||||
return hmac.new(key, msg.encode('utf-8'), sha256).digest()
|
||||
|
||||
_date = _sign((
|
||||
self.aws_auth_version +
|
||||
self.aws_secret_access_key).encode('utf-8'),
|
||||
reference.strftime('%Y%m%d'))
|
||||
|
||||
_region = _sign(_date, self.aws_region_name)
|
||||
_service = _sign(_region, self.aws_service_name)
|
||||
_signed = _sign(_service, self.aws_auth_request)
|
||||
return _sign(_signed, to_sign, to_hex=True)
|
||||
|
||||
@staticmethod
|
||||
def aws_response_to_dict(aws_response):
|
||||
"""
|
||||
Takes an AWS Response object as input and returns it as a dictionary
|
||||
but not befor extracting out what is useful to us first.
|
||||
|
||||
eg:
|
||||
IN:
|
||||
|
||||
<SendRawEmailResponse
|
||||
xmlns="http://ses.amazonaws.com/doc/2010-12-01/">
|
||||
<SendRawEmailResult>
|
||||
<MessageId>
|
||||
010f017d87656ee2-a2ea291f-79ea-
|
||||
44f3-9d25-00d041de3007-000000</MessageId>
|
||||
</SendRawEmailResult>
|
||||
<ResponseMetadata>
|
||||
<RequestId>7abb454e-904b-4e46-a23c-2f4d2fc127a6</RequestId>
|
||||
</ResponseMetadata>
|
||||
</SendRawEmailResponse>
|
||||
|
||||
OUT:
|
||||
{
|
||||
'type': 'SendRawEmailResponse',
|
||||
'message_id': '010f017d87656ee2-a2ea291f-79ea-
|
||||
44f3-9d25-00d041de3007-000000',
|
||||
'request_id': '7abb454e-904b-4e46-a23c-2f4d2fc127a6',
|
||||
}
|
||||
"""
|
||||
|
||||
# Define ourselves a set of directives we want to keep if found and
|
||||
# then identify the value we want to map them to in our response
|
||||
# object
|
||||
aws_keep_map = {
|
||||
'RequestId': 'request_id',
|
||||
'MessageId': 'message_id',
|
||||
|
||||
# Error Message Handling
|
||||
'Type': 'error_type',
|
||||
'Code': 'error_code',
|
||||
'Message': 'error_message',
|
||||
}
|
||||
|
||||
# A default response object that we'll manipulate as we pull more data
|
||||
# from our AWS Response object
|
||||
response = {
|
||||
'type': None,
|
||||
'request_id': None,
|
||||
'message_id': None,
|
||||
}
|
||||
|
||||
try:
|
||||
# we build our tree, but not before first eliminating any
|
||||
# reference to namespacing (if present) as it makes parsing
|
||||
# the tree so much easier.
|
||||
root = ElementTree.fromstring(
|
||||
re.sub(' xmlns="[^"]+"', '', aws_response, count=1))
|
||||
|
||||
# Store our response tag object name
|
||||
response['type'] = str(root.tag)
|
||||
|
||||
def _xml_iter(root, response):
|
||||
if len(root) > 0:
|
||||
for child in root:
|
||||
# use recursion to parse everything
|
||||
_xml_iter(child, response)
|
||||
|
||||
elif root.tag in aws_keep_map.keys():
|
||||
response[aws_keep_map[root.tag]] = (root.text).strip()
|
||||
|
||||
# Recursivly iterate over our AWS Response to extract the
|
||||
# fields we're interested in in efforts to populate our response
|
||||
# object.
|
||||
_xml_iter(root, response)
|
||||
|
||||
except (ElementTree.ParseError, TypeError):
|
||||
# bad data just causes us to generate a bad response
|
||||
pass
|
||||
|
||||
return response
|
||||
|
||||
def url(self, privacy=False, *args, **kwargs):
|
||||
"""
|
||||
Returns the URL built dynamically based on specified arguments.
|
||||
"""
|
||||
|
||||
# Acquire any global URL parameters
|
||||
params = self.url_parameters(privacy=privacy, *args, **kwargs)
|
||||
|
||||
if self.from_name is not None:
|
||||
# from_name specified; pass it back on the url
|
||||
params['name'] = self.from_name
|
||||
|
||||
if self.cc:
|
||||
# Handle our Carbon Copy Addresses
|
||||
params['cc'] = ','.join(
|
||||
['{}{}'.format(
|
||||
'' if not e not in self.names
|
||||
else '{}:'.format(self.names[e]), e) for e in self.cc])
|
||||
|
||||
if self.bcc:
|
||||
# Handle our Blind Carbon Copy Addresses
|
||||
params['bcc'] = ','.join(self.bcc)
|
||||
|
||||
if self.reply_to:
|
||||
# Handle our reply to address
|
||||
params['reply'] = '{} <{}>'.format(*self.reply_to) \
|
||||
if self.reply_to[0] else self.reply_to[1]
|
||||
|
||||
# a simple boolean check as to whether we display our target emails
|
||||
# or not
|
||||
has_targets = \
|
||||
not (len(self.targets) == 1
|
||||
and self.targets[0][1] == self.from_addr)
|
||||
|
||||
return '{schema}://{from_addr}/{key_id}/{key_secret}/{region}/' \
|
||||
'{targets}/?{params}'.format(
|
||||
schema=self.secure_protocol,
|
||||
from_addr=NotifySES.quote(self.from_addr, safe='@'),
|
||||
key_id=self.pprint(self.aws_access_key_id, privacy, safe=''),
|
||||
key_secret=self.pprint(
|
||||
self.aws_secret_access_key, privacy,
|
||||
mode=PrivacyMode.Secret, safe=''),
|
||||
region=NotifySES.quote(self.aws_region_name, safe=''),
|
||||
targets='' if not has_targets else '/'.join(
|
||||
[NotifySES.quote('{}{}'.format(
|
||||
'' if not e[0] else '{}:'.format(e[0]), e[1]),
|
||||
safe='') for e in self.targets]),
|
||||
params=NotifySES.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
|
||||
entries = NotifySES.split_path(results['fullpath'])
|
||||
|
||||
# The AWS Access Key ID is stored in the first entry
|
||||
access_key_id = entries.pop(0) if entries else None
|
||||
|
||||
# Our AWS Access Key Secret contains slashes in it which unfortunately
|
||||
# means it is of variable length after the hostname. Since we require
|
||||
# that the user provides the region code, we intentionally use this
|
||||
# as our delimiter to detect where our Secret is.
|
||||
secret_access_key = None
|
||||
region_name = None
|
||||
|
||||
# We need to iterate over each entry in the fullpath and find our
|
||||
# region. Once we get there we stop and build our secret from our
|
||||
# accumulated data.
|
||||
secret_access_key_parts = list()
|
||||
|
||||
# Section 1: Get Region and Access Secret
|
||||
index = 0
|
||||
for index, entry in enumerate(entries, start=1):
|
||||
|
||||
# Are we at the region yet?
|
||||
result = IS_REGION.match(entry)
|
||||
if result:
|
||||
# Ensure region is nicely formatted
|
||||
region_name = "{country}-{area}-{no}".format(
|
||||
country=result.group('country').lower(),
|
||||
area=result.group('area').lower(),
|
||||
no=result.group('no'),
|
||||
)
|
||||
|
||||
# We're done with Section 1 of our url (the credentials)
|
||||
break
|
||||
|
||||
elif is_email(entry):
|
||||
# We're done with Section 1 of our url (the credentials)
|
||||
index -= 1
|
||||
break
|
||||
|
||||
# Store our secret parts
|
||||
secret_access_key_parts.append(entry)
|
||||
|
||||
# Prepare our Secret Access Key
|
||||
secret_access_key = '/'.join(secret_access_key_parts) \
|
||||
if secret_access_key_parts else None
|
||||
|
||||
# Section 2: Get our Recipients (basically all remaining entries)
|
||||
results['targets'] = entries[index:]
|
||||
|
||||
if 'name' in results['qsd'] and len(results['qsd']['name']):
|
||||
# Extract from name to associate with from address
|
||||
results['from_name'] = \
|
||||
NotifySES.unquote(results['qsd']['name'])
|
||||
|
||||
# Handle 'to' email address
|
||||
if 'to' in results['qsd'] and len(results['qsd']['to']):
|
||||
results['targets'].append(results['qsd']['to'])
|
||||
|
||||
# Handle Carbon Copy Addresses
|
||||
if 'cc' in results['qsd'] and len(results['qsd']['cc']):
|
||||
results['cc'] = NotifySES.parse_list(results['qsd']['cc'])
|
||||
|
||||
# Handle Blind Carbon Copy Addresses
|
||||
if 'bcc' in results['qsd'] and len(results['qsd']['bcc']):
|
||||
results['bcc'] = NotifySES.parse_list(results['qsd']['bcc'])
|
||||
|
||||
# Handle From Address handling
|
||||
if 'from' in results['qsd'] and len(results['qsd']['from']):
|
||||
results['from_addr'] = \
|
||||
NotifySES.unquote(results['qsd']['from'])
|
||||
|
||||
# Handle Reply To Address
|
||||
if 'reply' in results['qsd'] and len(results['qsd']['reply']):
|
||||
results['reply_to'] = \
|
||||
NotifySES.unquote(results['qsd']['reply'])
|
||||
|
||||
# Handle secret_access_key over-ride
|
||||
if 'secret' in results['qsd'] and len(results['qsd']['secret']):
|
||||
results['secret_access_key'] = \
|
||||
NotifySES.unquote(results['qsd']['secret'])
|
||||
else:
|
||||
results['secret_access_key'] = secret_access_key
|
||||
|
||||
# Handle access key id over-ride
|
||||
if 'access' in results['qsd'] and len(results['qsd']['access']):
|
||||
results['access_key_id'] = \
|
||||
NotifySES.unquote(results['qsd']['access'])
|
||||
else:
|
||||
results['access_key_id'] = access_key_id
|
||||
|
||||
# Handle region name id over-ride
|
||||
if 'region' in results['qsd'] and len(results['qsd']['region']):
|
||||
results['region_name'] = \
|
||||
NotifySES.unquote(results['qsd']['region'])
|
||||
else:
|
||||
results['region_name'] = region_name
|
||||
|
||||
# Return our result set
|
||||
return results
|
|
@ -56,7 +56,7 @@ IS_TOPIC = re.compile(r'^#?(?P<name>[A-Za-z0-9_-]+)\s*$')
|
|||
# users of this product search though this Access Key Secret and escape all
|
||||
# of the forward slashes!
|
||||
IS_REGION = re.compile(
|
||||
r'^\s*(?P<country>[a-z]{2})-(?P<area>[a-z]+)-(?P<no>[0-9]+)\s*$', re.I)
|
||||
r'^\s*(?P<country>[a-z]{2})-(?P<area>[a-z-]+?)-(?P<no>[0-9]+)\s*$', re.I)
|
||||
|
||||
# Extend HTTP Error Messages
|
||||
AWS_HTTP_ERROR_MAP = {
|
||||
|
@ -116,7 +116,7 @@ class NotifySNS(NotifyBase):
|
|||
'name': _('Region'),
|
||||
'type': 'string',
|
||||
'required': True,
|
||||
'regex': (r'^[a-z]{2}-[a-z]+-[0-9]+$', 'i'),
|
||||
'regex': (r'^[a-z]{2}-[a-z-]+?-[0-9]+$', 'i'),
|
||||
'map_to': 'region_name',
|
||||
},
|
||||
'target_phone_no': {
|
||||
|
@ -143,6 +143,15 @@ class NotifySNS(NotifyBase):
|
|||
'to': {
|
||||
'alias_of': 'targets',
|
||||
},
|
||||
'access': {
|
||||
'alias_of': 'access_key_id',
|
||||
},
|
||||
'secret': {
|
||||
'alias_of': 'secret_access_key',
|
||||
},
|
||||
'region': {
|
||||
'alias_of': 'region',
|
||||
},
|
||||
})
|
||||
|
||||
def __init__(self, access_key_id, secret_access_key, region_name,
|
||||
|
@ -200,8 +209,8 @@ class NotifySNS(NotifyBase):
|
|||
for target in parse_list(targets):
|
||||
result = is_phone_no(target)
|
||||
if result:
|
||||
# store valid phone number
|
||||
self.phone.append('+{}'.format(result))
|
||||
# store valid phone number in E.164 format
|
||||
self.phone.append('+{}'.format(result['full']))
|
||||
continue
|
||||
|
||||
result = IS_TOPIC.match(target)
|
||||
|
@ -576,8 +585,8 @@ class NotifySNS(NotifyBase):
|
|||
region=NotifySNS.quote(self.aws_region_name, safe=''),
|
||||
targets='/'.join(
|
||||
[NotifySNS.quote(x) for x in chain(
|
||||
# Phone # are prefixed with a plus symbol
|
||||
['+{}'.format(x) for x in self.phone],
|
||||
# Phone # are already prefixed with a plus symbol
|
||||
self.phone,
|
||||
# Topics are prefixed with a pound/hashtag symbol
|
||||
['#{}'.format(x) for x in self.topics],
|
||||
)]),
|
||||
|
@ -651,10 +660,26 @@ class NotifySNS(NotifyBase):
|
|||
results['targets'] += \
|
||||
NotifySNS.parse_list(results['qsd']['to'])
|
||||
|
||||
# Store our other detected data (if at all)
|
||||
results['region_name'] = region_name
|
||||
results['access_key_id'] = access_key_id
|
||||
results['secret_access_key'] = secret_access_key
|
||||
# Handle secret_access_key over-ride
|
||||
if 'secret' in results['qsd'] and len(results['qsd']['secret']):
|
||||
results['secret_access_key'] = \
|
||||
NotifySNS.unquote(results['qsd']['secret'])
|
||||
else:
|
||||
results['secret_access_key'] = secret_access_key
|
||||
|
||||
# Handle access key id over-ride
|
||||
if 'access' in results['qsd'] and len(results['qsd']['access']):
|
||||
results['access_key_id'] = \
|
||||
NotifySNS.unquote(results['qsd']['access'])
|
||||
else:
|
||||
results['access_key_id'] = access_key_id
|
||||
|
||||
# Handle region name id over-ride
|
||||
if 'region' in results['qsd'] and len(results['qsd']['region']):
|
||||
results['region_name'] = \
|
||||
NotifySNS.unquote(results['qsd']['region'])
|
||||
else:
|
||||
results['region_name'] = region_name
|
||||
|
||||
# Return our result set
|
||||
return results
|
||||
|
|
173
libs/apprise/plugins/NotifyServerChan.py
Normal file
173
libs/apprise/plugins/NotifyServerChan.py
Normal file
|
@ -0,0 +1,173 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
#
|
||||
# Copyright (C) 2020 Chris Caron <xuzheliang135@qq.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 ..common import NotifyType
|
||||
from .NotifyBase import NotifyBase
|
||||
from ..utils import validate_regex
|
||||
from ..AppriseLocale import gettext_lazy as _
|
||||
|
||||
|
||||
# Register at https://sct.ftqq.com/
|
||||
# - do as the page describe and you will get the token
|
||||
|
||||
# Syntax:
|
||||
# schan://{access_token}/
|
||||
|
||||
|
||||
class NotifyServerChan(NotifyBase):
|
||||
"""
|
||||
A wrapper for ServerChan Notifications
|
||||
"""
|
||||
|
||||
# The default descriptive name associated with the Notification
|
||||
service_name = 'ServerChan'
|
||||
|
||||
# The services URL
|
||||
service_url = 'https://sct.ftqq.com/'
|
||||
|
||||
# All notification requests are secure
|
||||
secure_protocol = 'schan'
|
||||
|
||||
# A URL that takes you to the setup/help of the specific protocol
|
||||
setup_url = 'https://github.com/caronc/apprise/wiki/Notify_serverchan'
|
||||
|
||||
# ServerChan API
|
||||
notify_url = 'https://sctapi.ftqq.com/{token}.send'
|
||||
|
||||
# Define object templates
|
||||
templates = (
|
||||
'{schema}://{token}/',
|
||||
)
|
||||
|
||||
# Define our template tokens
|
||||
template_tokens = dict(NotifyBase.template_tokens, **{
|
||||
'token': {
|
||||
'name': _('Token'),
|
||||
'type': 'string',
|
||||
'private': True,
|
||||
'required': True,
|
||||
'regex': (r'^[a-z0-9]+$', 'i'),
|
||||
},
|
||||
})
|
||||
|
||||
def __init__(self, token, **kwargs):
|
||||
"""
|
||||
Initialize ServerChan Object
|
||||
"""
|
||||
super(NotifyServerChan, self).__init__(**kwargs)
|
||||
|
||||
# Token (associated with project)
|
||||
self.token = validate_regex(
|
||||
token, *self.template_tokens['token']['regex'])
|
||||
if not self.token:
|
||||
msg = 'An invalid ServerChan API Token ' \
|
||||
'({}) was specified.'.format(token)
|
||||
self.logger.warning(msg)
|
||||
raise TypeError(msg)
|
||||
|
||||
def send(self, body, title='', notify_type=NotifyType.INFO, **kwargs):
|
||||
"""
|
||||
Perform ServerChan Notification
|
||||
"""
|
||||
payload = {
|
||||
'title': title,
|
||||
'desp': body,
|
||||
}
|
||||
|
||||
# Our Notification URL
|
||||
notify_url = self.notify_url.format(token=self.token)
|
||||
|
||||
# Some Debug Logging
|
||||
self.logger.debug('ServerChan URL: {} (cert_verify={})'.format(
|
||||
notify_url, self.verify_certificate))
|
||||
self.logger.debug('ServerChan Payload: {}'.format(payload))
|
||||
|
||||
# Always call throttle before any remote server i/o is made
|
||||
self.throttle()
|
||||
|
||||
try:
|
||||
r = requests.post(
|
||||
notify_url,
|
||||
data=payload,
|
||||
)
|
||||
|
||||
if r.status_code != requests.codes.ok:
|
||||
# We had a problem
|
||||
status_str = \
|
||||
NotifyServerChan.http_response_code_lookup(
|
||||
r.status_code)
|
||||
|
||||
self.logger.warning(
|
||||
'Failed to send ServerChan notification: '
|
||||
'{}{}error={}.'.format(
|
||||
status_str,
|
||||
', ' if status_str else '',
|
||||
r.status_code))
|
||||
|
||||
self.logger.debug(
|
||||
'Response Details:\r\n{}'.format(r.content))
|
||||
return False
|
||||
|
||||
else:
|
||||
self.logger.info('Sent ServerChan notification.')
|
||||
|
||||
except requests.RequestException as e:
|
||||
self.logger.warning(
|
||||
'A Connection error occured sending ServerChan '
|
||||
'notification.'
|
||||
)
|
||||
self.logger.debug('Socket Exception: %s' % str(e))
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
def url(self, privacy=False):
|
||||
"""
|
||||
Returns the URL built dynamically based on specified arguments.
|
||||
"""
|
||||
|
||||
return '{schema}://{token}'.format(
|
||||
schema=self.secure_protocol,
|
||||
token=self.pprint(self.token, privacy, safe=''))
|
||||
|
||||
@staticmethod
|
||||
def parse_url(url):
|
||||
"""
|
||||
Parses the URL and returns enough arguments that can allow
|
||||
us to substantiate this object.
|
||||
"""
|
||||
results = NotifyBase.parse_url(url, verify_host=False)
|
||||
if not results:
|
||||
# We're done early as we couldn't parse the URL
|
||||
return results
|
||||
|
||||
pattern = 'schan://([a-zA-Z0-9]+)/' + \
|
||||
('?' if not url.endswith('/') else '')
|
||||
result = re.match(pattern, url)
|
||||
results['token'] = result.group(1) if result else ''
|
||||
return results
|
400
libs/apprise/plugins/NotifySignalAPI.py
Normal file
400
libs/apprise/plugins/NotifySignalAPI.py
Normal file
|
@ -0,0 +1,400 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
#
|
||||
# Copyright (C) 2020 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 requests
|
||||
from json import dumps
|
||||
|
||||
from .NotifyBase import NotifyBase
|
||||
from ..common import NotifyType
|
||||
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 _
|
||||
|
||||
|
||||
class NotifySignalAPI(NotifyBase):
|
||||
"""
|
||||
A wrapper for SignalAPI Notifications
|
||||
"""
|
||||
|
||||
# The default descriptive name associated with the Notification
|
||||
service_name = 'Signal API'
|
||||
|
||||
# The services URL
|
||||
service_url = 'https://bbernhard.github.io/signal-cli-rest-api/'
|
||||
|
||||
# The default protocol
|
||||
protocol = 'signal'
|
||||
|
||||
# The default protocol
|
||||
secure_protocol = 'signals'
|
||||
|
||||
# A URL that takes you to the setup/help of the specific protocol
|
||||
setup_url = 'https://github.com/caronc/apprise/wiki/Notify_signal'
|
||||
|
||||
# The maximum targets to include when doing batch transfers
|
||||
default_batch_size = 10
|
||||
|
||||
# We don't support titles for Signal notifications
|
||||
title_maxlen = 0
|
||||
|
||||
# Define object templates
|
||||
templates = (
|
||||
'{schema}://{host}/{from_phone}',
|
||||
'{schema}://{host}:{port}/{from_phone}',
|
||||
'{schema}://{user}@{host}/{from_phone}',
|
||||
'{schema}://{user}@{host}:{port}/{from_phone}',
|
||||
'{schema}://{user}:{password}@{host}/{from_phone}',
|
||||
'{schema}://{user}:{password}@{host}:{port}/{from_phone}',
|
||||
'{schema}://{host}/{from_phone}/{targets}',
|
||||
'{schema}://{host}:{port}/{from_phone}/{targets}',
|
||||
'{schema}://{user}@{host}/{from_phone}/{targets}',
|
||||
'{schema}://{user}@{host}:{port}/{from_phone}/{targets}',
|
||||
'{schema}://{user}:{password}@{host}/{from_phone}/{targets}',
|
||||
'{schema}://{user}:{password}@{host}:{port}/{from_phone}/{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,
|
||||
},
|
||||
'user': {
|
||||
'name': _('Username'),
|
||||
'type': 'string',
|
||||
},
|
||||
'password': {
|
||||
'name': _('Password'),
|
||||
'type': 'string',
|
||||
'private': True,
|
||||
},
|
||||
'from_phone': {
|
||||
'name': _('From Phone No'),
|
||||
'type': 'string',
|
||||
'required': True,
|
||||
'regex': (r'^\+?[0-9\s)(+-]+$', 'i'),
|
||||
'map_to': 'source',
|
||||
},
|
||||
'target_phone': {
|
||||
'name': _('Target Phone No'),
|
||||
'type': 'string',
|
||||
'prefix': '+',
|
||||
'regex': (r'^[0-9\s)(+-]+$', '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': {
|
||||
'alias_of': 'from_phone',
|
||||
},
|
||||
'batch': {
|
||||
'name': _('Batch Mode'),
|
||||
'type': 'bool',
|
||||
'default': False,
|
||||
},
|
||||
'status': {
|
||||
'name': _('Show Status'),
|
||||
'type': 'bool',
|
||||
'default': False,
|
||||
},
|
||||
})
|
||||
|
||||
def __init__(self, source=None, targets=None, batch=False, status=False,
|
||||
**kwargs):
|
||||
"""
|
||||
Initialize SignalAPI Object
|
||||
"""
|
||||
super(NotifySignalAPI, self).__init__(**kwargs)
|
||||
|
||||
# Prepare Batch Mode Flag
|
||||
self.batch = batch
|
||||
|
||||
# Set Status type
|
||||
self.status = status
|
||||
|
||||
# Parse our targets
|
||||
self.targets = list()
|
||||
|
||||
# Used for URL generation afterwards only
|
||||
self.invalid_targets = list()
|
||||
|
||||
# Manage our Source Phone
|
||||
result = is_phone_no(source)
|
||||
if not result:
|
||||
msg = 'An invalid Signal API Source Phone No ' \
|
||||
'({}) was provided.'.format(source)
|
||||
self.logger.warning(msg)
|
||||
raise TypeError(msg)
|
||||
|
||||
self.source = '+{}'.format(result['full'])
|
||||
|
||||
if targets:
|
||||
# Validate our targerts
|
||||
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)
|
||||
continue
|
||||
|
||||
# store valid phone number
|
||||
self.targets.append('+{}'.format(result['full']))
|
||||
else:
|
||||
# Send a message to ourselves
|
||||
self.targets.append(self.source)
|
||||
|
||||
return
|
||||
|
||||
def send(self, body, title='', notify_type=NotifyType.INFO, **kwargs):
|
||||
"""
|
||||
Perform Signal API Notification
|
||||
"""
|
||||
|
||||
if len(self.targets) == 0:
|
||||
# There were no services to notify
|
||||
self.logger.warning(
|
||||
'There were no Signal API 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',
|
||||
}
|
||||
|
||||
# Prepare our payload
|
||||
payload = {
|
||||
'message': "{}{}".format(
|
||||
'' if not self.status else '{} '.format(
|
||||
self.asset.ascii(notify_type)), body),
|
||||
"number": self.source,
|
||||
"recipients": []
|
||||
}
|
||||
|
||||
# Determine Authentication
|
||||
auth = None
|
||||
if self.user:
|
||||
auth = (self.user, self.password)
|
||||
|
||||
# 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 += '/v2/send'
|
||||
|
||||
# Send in batches if identified to do so
|
||||
batch_size = 1 if not self.batch else self.default_batch_size
|
||||
|
||||
for index in range(0, len(self.targets), batch_size):
|
||||
# Prepare our recipients
|
||||
payload['recipients'] = self.targets[index:index + batch_size]
|
||||
|
||||
self.logger.debug('Signal API POST URL: %s (cert_verify=%r)' % (
|
||||
notify_url, self.verify_certificate,
|
||||
))
|
||||
self.logger.debug('Signal API Payload: %s' % str(payload))
|
||||
|
||||
# Always call throttle before any remote server i/o is made
|
||||
self.throttle()
|
||||
try:
|
||||
r = requests.post(
|
||||
notify_url,
|
||||
auth=auth,
|
||||
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 = \
|
||||
NotifySignalAPI.http_response_code_lookup(
|
||||
r.status_code)
|
||||
|
||||
self.logger.warning(
|
||||
'Failed to send {} Signal API notification{}: '
|
||||
'{}{}error={}.'.format(
|
||||
len(self.targets[index:index + batch_size]),
|
||||
' to {}'.format(self.targets[index])
|
||||
if batch_size == 1 else '(s)',
|
||||
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 {} Signal API notification{}.'
|
||||
.format(
|
||||
len(self.targets[index:index + batch_size]),
|
||||
' to {}'.format(self.targets[index])
|
||||
if batch_size == 1 else '(s)',
|
||||
))
|
||||
|
||||
except requests.RequestException as e:
|
||||
self.logger.warning(
|
||||
'A Connection error occured sending {} Signal API '
|
||||
'notification(s).'.format(
|
||||
len(self.targets[index:index + batch_size])))
|
||||
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',
|
||||
}
|
||||
|
||||
# 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=NotifySignalAPI.quote(self.user, safe=''),
|
||||
password=self.pprint(
|
||||
self.password, privacy, mode=PrivacyMode.Secret, safe=''),
|
||||
)
|
||||
elif self.user:
|
||||
auth = '{user}@'.format(
|
||||
user=NotifySignalAPI.quote(self.user, safe=''),
|
||||
)
|
||||
|
||||
default_port = 443 if self.secure else 80
|
||||
|
||||
# So we can strip out our own phone (if present); create a copy of our
|
||||
# targets
|
||||
if len(self.targets) == 1 and self.source in self.targets:
|
||||
targets = []
|
||||
|
||||
elif len(self.targets) == 0:
|
||||
# invalid phone-no were specified
|
||||
targets = self.invalid_targets
|
||||
|
||||
else:
|
||||
targets = list(self.targets)
|
||||
|
||||
return '{schema}://{auth}{hostname}{port}/{src}/{dst}?{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),
|
||||
src=self.source,
|
||||
dst='/'.join(
|
||||
[NotifySignalAPI.quote(x, safe='') for x in targets]),
|
||||
params=NotifySignalAPI.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'] = \
|
||||
NotifySignalAPI.split_path(results['fullpath'])
|
||||
|
||||
# The hostname is our authentication key
|
||||
results['apikey'] = NotifySignalAPI.unquote(results['host'])
|
||||
|
||||
if 'from' in results['qsd'] and len(results['qsd']['from']):
|
||||
results['source'] = \
|
||||
NotifySignalAPI.unquote(results['qsd']['from'])
|
||||
|
||||
elif results['targets']:
|
||||
# The from phone no is the first entry in the list otherwise
|
||||
results['source'] = results['targets'].pop(0)
|
||||
|
||||
# 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'] += \
|
||||
NotifySignalAPI.parse_phone_no(results['qsd']['to'])
|
||||
|
||||
# Get Batch Mode Flag
|
||||
results['batch'] = \
|
||||
parse_bool(results['qsd'].get('batch', False))
|
||||
|
||||
# Get status switch
|
||||
results['status'] = \
|
||||
parse_bool(results['qsd'].get('status', False))
|
||||
|
||||
return results
|
|
@ -316,10 +316,6 @@ class NotifySlack(NotifyBase):
|
|||
self.logger.warning(msg)
|
||||
raise TypeError(msg)
|
||||
|
||||
if not self.user:
|
||||
self.logger.warning(
|
||||
'No user was specified; using "%s".' % self.app_id)
|
||||
|
||||
# Look the users up by their email address and map them back to their
|
||||
# id here for future queries (if needed). This allows people to
|
||||
# specify a full email as a recipient via slack
|
||||
|
|
|
@ -93,6 +93,9 @@ class NotifyTelegram(NotifyBase):
|
|||
# A URL that takes you to the setup/help of the specific protocol
|
||||
setup_url = 'https://github.com/caronc/apprise/wiki/Notify_telegram'
|
||||
|
||||
# Default Notify Format
|
||||
notify_format = NotifyFormat.HTML
|
||||
|
||||
# Telegram uses the http protocol with JSON requests
|
||||
notify_url = 'https://api.telegram.org/bot'
|
||||
|
||||
|
@ -102,6 +105,9 @@ class NotifyTelegram(NotifyBase):
|
|||
# The maximum allowable characters allowed in the body per message
|
||||
body_maxlen = 4096
|
||||
|
||||
# Title is to be part of body
|
||||
title_maxlen = 0
|
||||
|
||||
# Telegram is limited to sending a maximum of 100 requests per second.
|
||||
request_rate_per_sec = 0.001
|
||||
|
||||
|
@ -167,6 +173,49 @@ class NotifyTelegram(NotifyBase):
|
|||
},
|
||||
)
|
||||
|
||||
# Telegram's HTML support doesn't like having HTML escaped
|
||||
# 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>',
|
||||
|
||||
# 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*/(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>',
|
||||
|
||||
# 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>',
|
||||
|
||||
# HTML Spaces ( ) and tabs ( ) aren't supported
|
||||
# See https://core.telegram.org/bots/api#html-style
|
||||
re.compile(r'\ ?', re.I): ' ',
|
||||
|
||||
# Tabs become 3 spaces
|
||||
re.compile(r'\ ?', re.I): ' ',
|
||||
|
||||
# Some characters get re-escaped by the Telegram upstream
|
||||
# service so we need to convert these back,
|
||||
re.compile(r'\'?', re.I): '\'',
|
||||
re.compile(r'\"?', re.I): '"',
|
||||
}
|
||||
|
||||
# Define our template tokens
|
||||
template_tokens = dict(NotifyBase.template_tokens, **{
|
||||
'bot_token': {
|
||||
|
@ -483,15 +532,15 @@ class NotifyTelegram(NotifyBase):
|
|||
# "text":"/start",
|
||||
# "entities":[{"offset":0,"length":6,"type":"bot_command"}]}}]
|
||||
|
||||
if 'ok' in response and response['ok'] is True \
|
||||
and 'result' in response and len(response['result']):
|
||||
entry = response['result'][0]
|
||||
_id = entry['message']['from'].get('id', 0)
|
||||
_user = entry['message']['from'].get('first_name')
|
||||
self.logger.info('Detected Telegram user %s (userid=%d)' % (
|
||||
_user, _id))
|
||||
# Return our detected userid
|
||||
return _id
|
||||
if response.get('ok', False):
|
||||
for entry in response.get('result', []):
|
||||
if 'message' in entry and 'from' in entry['message']:
|
||||
_id = entry['message']['from'].get('id', 0)
|
||||
_user = entry['message']['from'].get('first_name')
|
||||
self.logger.info(
|
||||
'Detected Telegram user %s (userid=%d)' % (_user, _id))
|
||||
# Return our detected userid
|
||||
return _id
|
||||
|
||||
self.logger.warning(
|
||||
'Failed to detect a Telegram user; '
|
||||
|
@ -499,7 +548,7 @@ class NotifyTelegram(NotifyBase):
|
|||
return 0
|
||||
|
||||
def send(self, body, title='', notify_type=NotifyType.INFO, attach=None,
|
||||
**kwargs):
|
||||
body_format=None, **kwargs):
|
||||
"""
|
||||
Perform Telegram Notification
|
||||
"""
|
||||
|
@ -538,87 +587,47 @@ class NotifyTelegram(NotifyBase):
|
|||
'disable_web_page_preview': not self.preview,
|
||||
}
|
||||
|
||||
# Prepare Email Message
|
||||
# Prepare Message Body
|
||||
if self.notify_format == NotifyFormat.MARKDOWN:
|
||||
payload['parse_mode'] = 'MARKDOWN'
|
||||
|
||||
payload['text'] = '{}{}'.format(
|
||||
'{}\r\n'.format(title) if title else '',
|
||||
body,
|
||||
)
|
||||
payload['text'] = body
|
||||
|
||||
else: # HTML or TEXT
|
||||
else: # HTML
|
||||
|
||||
# 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)
|
||||
|
||||
# Telegram's HTML support doesn't like having HTML escaped
|
||||
# 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 = {
|
||||
# HTML Spaces ( ) and tabs ( ) aren't supported
|
||||
# See https://core.telegram.org/bots/api#html-style
|
||||
r'nbsp': ' ',
|
||||
# Prepare our payload based on HTML or TEXT
|
||||
payload['text'] = body
|
||||
|
||||
# Tabs become 3 spaces
|
||||
r'emsp': ' ',
|
||||
# else: # self.notify_format == NotifyFormat.TEXT:
|
||||
# # Use Telegram's HTML mode
|
||||
# payload['parse_mode'] = 'HTML'
|
||||
|
||||
# Some characters get re-escaped by the Telegram upstream
|
||||
# service so we need to convert these back,
|
||||
r'apos': '\'',
|
||||
r'quot': '"',
|
||||
}
|
||||
# # Further html escaping required...
|
||||
# telegram_escape_text_dict = {
|
||||
# # We need to escape characters that conflict with html
|
||||
# # entity blocks (< and >) when displaying text
|
||||
# r'>': '>',
|
||||
# r'<': '<',
|
||||
# r'\&': '&',
|
||||
# }
|
||||
|
||||
# Create a regular expression from the dictionary keys
|
||||
html_regex = re.compile("&(%s);?" % "|".join(
|
||||
map(re.escape, telegram_escape_html_dict.keys())).lower(),
|
||||
re.I)
|
||||
# # 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
|
||||
# we look +1 to ignore the & that does not appear in the index
|
||||
# we only look at the first 4 characters because we don't want to
|
||||
# fail on ' as it's accepted (along with &apos - no
|
||||
# semi-colon)
|
||||
body = html_regex.sub( # pragma: no branch
|
||||
lambda mo: telegram_escape_html_dict[
|
||||
mo.string[mo.start():mo.end()][1:5]], body)
|
||||
# # 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)
|
||||
|
||||
if title:
|
||||
# For each match, look-up corresponding value in dictionary
|
||||
# Indexing is explained above (for how the body is parsed)
|
||||
title = html_regex.sub( # pragma: no branch
|
||||
lambda mo: telegram_escape_html_dict[
|
||||
mo.string[mo.start():mo.end()][1:5]], title)
|
||||
|
||||
if self.notify_format == NotifyFormat.TEXT:
|
||||
telegram_escape_text_dict = {
|
||||
# We need to escape characters that conflict with html
|
||||
# entity blocks (< and >) when displaying text
|
||||
r'>': '>',
|
||||
r'<': '<',
|
||||
}
|
||||
|
||||
# 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)
|
||||
|
||||
if title:
|
||||
# For each match, look-up corresponding value in dictionary
|
||||
title = text_regex.sub( # pragma: no branch
|
||||
lambda mo: telegram_escape_text_dict[
|
||||
mo.string[mo.start():mo.end()]], title)
|
||||
|
||||
payload['text'] = '{}{}'.format(
|
||||
'<b>{}</b>\r\n'.format(title) if title else '',
|
||||
body,
|
||||
)
|
||||
# # prepare our payload based on HTML or TEXT
|
||||
# payload['text'] = body
|
||||
|
||||
# Create a copy of the chat_ids list
|
||||
targets = list(self.targets)
|
||||
|
|
|
@ -28,6 +28,7 @@
|
|||
import re
|
||||
import six
|
||||
import requests
|
||||
from copy import deepcopy
|
||||
from datetime import datetime
|
||||
from requests_oauthlib import OAuth1
|
||||
from json import dumps
|
||||
|
@ -39,6 +40,7 @@ from ..utils import parse_list
|
|||
from ..utils import parse_bool
|
||||
from ..utils import validate_regex
|
||||
from ..AppriseLocale import gettext_lazy as _
|
||||
from ..attachment.AttachBase import AttachBase
|
||||
|
||||
IS_USER = re.compile(r'^\s*@?(?P<user>[A-Z0-9_]+)$', re.I)
|
||||
|
||||
|
@ -87,9 +89,6 @@ class NotifyTwitter(NotifyBase):
|
|||
# Twitter does have titles when creating a message
|
||||
title_maxlen = 0
|
||||
|
||||
# Twitter API
|
||||
twitter_api = 'api.twitter.com'
|
||||
|
||||
# Twitter API Reference To Acquire Someone's Twitter ID
|
||||
twitter_lookup = 'https://api.twitter.com/1.1/users/lookup.json'
|
||||
|
||||
|
@ -103,6 +102,13 @@ class NotifyTwitter(NotifyBase):
|
|||
# Twitter API Reference To Send A Public Tweet
|
||||
twitter_tweet = 'https://api.twitter.com/1.1/statuses/update.json'
|
||||
|
||||
# it is documented on the site that the maximum images per tweet
|
||||
# is 4 (unless it's a GIF, then it's only 1)
|
||||
__tweet_non_gif_images_batch = 4
|
||||
|
||||
# Twitter Media (Attachment) Upload Location
|
||||
twitter_media = 'https://upload.twitter.com/1.1/media/upload.json'
|
||||
|
||||
# Twitter is kind enough to return how many more requests we're allowed to
|
||||
# continue to make within it's header response as:
|
||||
# X-Rate-Limit-Reset: The epoc time (in seconds) we can expect our
|
||||
|
@ -176,10 +182,15 @@ class NotifyTwitter(NotifyBase):
|
|||
'to': {
|
||||
'alias_of': 'targets',
|
||||
},
|
||||
'batch': {
|
||||
'name': _('Batch Mode'),
|
||||
'type': 'bool',
|
||||
'default': True,
|
||||
},
|
||||
})
|
||||
|
||||
def __init__(self, ckey, csecret, akey, asecret, targets=None,
|
||||
mode=TwitterMessageMode.DM, cache=True, **kwargs):
|
||||
mode=TwitterMessageMode.DM, cache=True, batch=True, **kwargs):
|
||||
"""
|
||||
Initialize Twitter Object
|
||||
|
||||
|
@ -217,6 +228,9 @@ class NotifyTwitter(NotifyBase):
|
|||
# Set Cache Flag
|
||||
self.cache = cache
|
||||
|
||||
# Prepare Image Batch Mode Flag
|
||||
self.batch = batch
|
||||
|
||||
if self.mode not in TWITTER_MESSAGE_MODES:
|
||||
msg = 'The Twitter message mode specified ({}) is invalid.' \
|
||||
.format(mode)
|
||||
|
@ -250,42 +264,196 @@ class NotifyTwitter(NotifyBase):
|
|||
|
||||
return
|
||||
|
||||
def send(self, body, title='', notify_type=NotifyType.INFO, **kwargs):
|
||||
def send(self, body, title='', notify_type=NotifyType.INFO, attach=None,
|
||||
**kwargs):
|
||||
"""
|
||||
Perform Twitter Notification
|
||||
"""
|
||||
|
||||
# Call the _send_ function applicable to whatever mode we're in
|
||||
# Build a list of our attachments
|
||||
attachments = []
|
||||
|
||||
if attach:
|
||||
# We need to upload our payload first so that we can source it
|
||||
# in remaining messages
|
||||
for attachment in attach:
|
||||
|
||||
# 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 Twitter attachment {}.'.format(
|
||||
attachment.url(privacy=True)))
|
||||
continue
|
||||
|
||||
self.logger.debug(
|
||||
'Preparing Twiter attachment {}'.format(
|
||||
attachment.url(privacy=True)))
|
||||
|
||||
# Upload our image and get our id associated with it
|
||||
# see: https://developer.twitter.com/en/docs/twitter-api/v1/\
|
||||
# media/upload-media/api-reference/post-media-upload
|
||||
postokay, response = self._fetch(
|
||||
self.twitter_media,
|
||||
payload=attachment,
|
||||
)
|
||||
|
||||
if not postokay:
|
||||
# We can't post our attachment
|
||||
return False
|
||||
|
||||
if not (isinstance(response, dict)
|
||||
and response.get('media_id')):
|
||||
self.logger.debug(
|
||||
'Could not attach the file to Twitter: %s (mime=%s)',
|
||||
attachment.name, attachment.mimetype)
|
||||
continue
|
||||
|
||||
# If we get here, our output will look something like this:
|
||||
# {
|
||||
# "media_id": 710511363345354753,
|
||||
# "media_id_string": "710511363345354753",
|
||||
# "media_key": "3_710511363345354753",
|
||||
# "size": 11065,
|
||||
# "expires_after_secs": 86400,
|
||||
# "image": {
|
||||
# "image_type": "image/jpeg",
|
||||
# "w": 800,
|
||||
# "h": 320
|
||||
# }
|
||||
# }
|
||||
|
||||
response.update({
|
||||
# Update our response to additionally include the
|
||||
# attachment details
|
||||
'file_name': attachment.name,
|
||||
'file_mime': attachment.mimetype,
|
||||
'file_path': attachment.path,
|
||||
})
|
||||
|
||||
# Save our pre-prepared payload for attachment posting
|
||||
attachments.append(response)
|
||||
|
||||
# - calls _send_tweet if the mode is set so
|
||||
# - calls _send_dm (direct message) otherwise
|
||||
return getattr(self, '_send_{}'.format(self.mode))(
|
||||
body=body, title=title, notify_type=notify_type, **kwargs)
|
||||
body=body, title=title, notify_type=notify_type,
|
||||
attachments=attachments, **kwargs)
|
||||
|
||||
def _send_tweet(self, body, title='', notify_type=NotifyType.INFO,
|
||||
**kwargs):
|
||||
attachments=None, **kwargs):
|
||||
"""
|
||||
Twitter Public Tweet
|
||||
"""
|
||||
|
||||
# Error Tracking
|
||||
has_error = False
|
||||
|
||||
payload = {
|
||||
'status': body,
|
||||
}
|
||||
|
||||
# Send Tweet
|
||||
postokay, response = self._fetch(
|
||||
self.twitter_tweet,
|
||||
payload=payload,
|
||||
json=False,
|
||||
)
|
||||
payloads = []
|
||||
if not attachments:
|
||||
payloads.append(payload)
|
||||
|
||||
else:
|
||||
# Group our images if batch is set to do so
|
||||
batch_size = 1 if not self.batch \
|
||||
else self.__tweet_non_gif_images_batch
|
||||
|
||||
# Track our batch control in our message generation
|
||||
batches = []
|
||||
batch = []
|
||||
for attachment in attachments:
|
||||
batch.append(str(attachment['media_id']))
|
||||
|
||||
# Twitter supports batching images together. This allows
|
||||
# the batching of multiple images together. Twitter also
|
||||
# makes it clear that you can't batch `gif` files; they need
|
||||
# to be separate. So the below preserves the ordering that
|
||||
# a user passed their attachments in. if 4-non-gif images
|
||||
# are passed, they are all part of a single message.
|
||||
#
|
||||
# however, if they pass in image, gif, image, gif. The
|
||||
# gif's inbetween break apart the batches so this would
|
||||
# produce 4 separate tweets.
|
||||
#
|
||||
# If you passed in, image, image, gif, image. <- This would
|
||||
# produce 3 images (as the first 2 images could be lumped
|
||||
# together as a batch)
|
||||
if not re.match(
|
||||
r'^image/(png|jpe?g)', attachment['file_mime'], re.I) \
|
||||
or len(batch) >= batch_size:
|
||||
batches.append(','.join(batch))
|
||||
batch = []
|
||||
|
||||
if batch:
|
||||
batches.append(','.join(batch))
|
||||
|
||||
for no, media_ids in enumerate(batches):
|
||||
_payload = deepcopy(payload)
|
||||
_payload['media_ids'] = media_ids
|
||||
|
||||
if no:
|
||||
# strip text and replace it with the image representation
|
||||
_payload['status'] = \
|
||||
'{:02d}/{:02d}'.format(no + 1, len(batches))
|
||||
payloads.append(_payload)
|
||||
|
||||
for no, payload in enumerate(payloads, start=1):
|
||||
# Send Tweet
|
||||
postokay, response = self._fetch(
|
||||
self.twitter_tweet,
|
||||
payload=payload,
|
||||
json=False,
|
||||
)
|
||||
|
||||
if not postokay:
|
||||
# Track our error
|
||||
has_error = True
|
||||
|
||||
errors = []
|
||||
try:
|
||||
errors = ['Error Code {}: {}'.format(
|
||||
e.get('code', 'unk'), e.get('message'))
|
||||
for e in response['errors']]
|
||||
|
||||
except (KeyError, TypeError):
|
||||
pass
|
||||
|
||||
for error in errors:
|
||||
self.logger.debug(
|
||||
'Tweet [%.2d/%.2d] Details: %s',
|
||||
no, len(payloads), error)
|
||||
continue
|
||||
|
||||
try:
|
||||
url = 'https://twitter.com/{}/status/{}'.format(
|
||||
response['user']['screen_name'],
|
||||
response['id_str'])
|
||||
|
||||
except (KeyError, TypeError):
|
||||
url = 'unknown'
|
||||
|
||||
self.logger.debug(
|
||||
'Tweet [%.2d/%.2d] Details: %s', no, len(payloads), url)
|
||||
|
||||
if postokay:
|
||||
self.logger.info(
|
||||
'Sent Twitter notification as public tweet.')
|
||||
'Sent [%.2d/%.2d] Twitter notification as public tweet.',
|
||||
no, len(payloads))
|
||||
|
||||
return postokay
|
||||
return not has_error
|
||||
|
||||
def _send_dm(self, body, title='', notify_type=NotifyType.INFO,
|
||||
**kwargs):
|
||||
attachments=None, **kwargs):
|
||||
"""
|
||||
Twitter Direct Message
|
||||
"""
|
||||
|
@ -318,24 +486,48 @@ class NotifyTwitter(NotifyBase):
|
|||
'Failed to acquire user(s) to Direct Message via Twitter')
|
||||
return False
|
||||
|
||||
for screen_name, user_id in targets.items():
|
||||
# Assign our user
|
||||
payload['event']['message_create']['target']['recipient_id'] = \
|
||||
user_id
|
||||
payloads = []
|
||||
if not attachments:
|
||||
payloads.append(payload)
|
||||
|
||||
# Send Twitter DM
|
||||
postokay, response = self._fetch(
|
||||
self.twitter_dm,
|
||||
payload=payload,
|
||||
)
|
||||
else:
|
||||
for no, attachment in enumerate(attachments):
|
||||
_payload = deepcopy(payload)
|
||||
_data = _payload['event']['message_create']['message_data']
|
||||
_data['attachment'] = {
|
||||
'type': 'media',
|
||||
'media': {
|
||||
'id': attachment['media_id']
|
||||
},
|
||||
'additional_owners':
|
||||
','.join([str(x) for x in targets.values()])
|
||||
}
|
||||
if no:
|
||||
# strip text and replace it with the image representation
|
||||
_data['text'] = \
|
||||
'{:02d}/{:02d}'.format(no + 1, len(attachments))
|
||||
payloads.append(_payload)
|
||||
|
||||
if not postokay:
|
||||
# Track our error
|
||||
has_error = True
|
||||
continue
|
||||
for no, payload in enumerate(payloads, start=1):
|
||||
for screen_name, user_id in targets.items():
|
||||
# Assign our user
|
||||
target = payload['event']['message_create']['target']
|
||||
target['recipient_id'] = user_id
|
||||
|
||||
self.logger.info(
|
||||
'Sent Twitter DM notification to @{}.'.format(screen_name))
|
||||
# Send Twitter DM
|
||||
postokay, response = self._fetch(
|
||||
self.twitter_dm,
|
||||
payload=payload,
|
||||
)
|
||||
|
||||
if not postokay:
|
||||
# Track our error
|
||||
has_error = True
|
||||
continue
|
||||
|
||||
self.logger.info(
|
||||
'Sent [{:02d}/{:02d}] Twitter DM notification to @{}.'
|
||||
.format(no, len(payloads), screen_name))
|
||||
|
||||
return not has_error
|
||||
|
||||
|
@ -458,13 +650,23 @@ class NotifyTwitter(NotifyBase):
|
|||
"""
|
||||
|
||||
headers = {
|
||||
'Host': self.twitter_api,
|
||||
'User-Agent': self.app_id,
|
||||
}
|
||||
|
||||
if json:
|
||||
data = None
|
||||
files = None
|
||||
|
||||
# Open our attachment path if required:
|
||||
if isinstance(payload, AttachBase):
|
||||
# prepare payload
|
||||
files = {'media': (payload.name, open(payload.path, 'rb'))}
|
||||
|
||||
elif json:
|
||||
headers['Content-Type'] = 'application/json'
|
||||
payload = dumps(payload)
|
||||
data = dumps(payload)
|
||||
|
||||
else:
|
||||
data = payload
|
||||
|
||||
auth = OAuth1(
|
||||
self.ckey,
|
||||
|
@ -506,13 +708,23 @@ class NotifyTwitter(NotifyBase):
|
|||
try:
|
||||
r = fn(
|
||||
url,
|
||||
data=payload,
|
||||
data=data,
|
||||
files=files,
|
||||
headers=headers,
|
||||
auth=auth,
|
||||
verify=self.verify_certificate,
|
||||
timeout=self.request_timeout,
|
||||
)
|
||||
|
||||
try:
|
||||
content = loads(r.content)
|
||||
|
||||
except (AttributeError, TypeError, ValueError):
|
||||
# ValueError = r.content is Unparsable
|
||||
# TypeError = r.content is None
|
||||
# AttributeError = r is None
|
||||
content = {}
|
||||
|
||||
if r.status_code != requests.codes.ok:
|
||||
# We had a problem
|
||||
status_str = \
|
||||
|
@ -532,15 +744,6 @@ class NotifyTwitter(NotifyBase):
|
|||
# Mark our failure
|
||||
return (False, content)
|
||||
|
||||
try:
|
||||
content = loads(r.content)
|
||||
|
||||
except (AttributeError, TypeError, ValueError):
|
||||
# ValueError = r.content is Unparsable
|
||||
# TypeError = r.content is None
|
||||
# AttributeError = r is None
|
||||
content = {}
|
||||
|
||||
try:
|
||||
# Capture rate limiting if possible
|
||||
self.ratelimit_remaining = \
|
||||
|
@ -562,6 +765,20 @@ class NotifyTwitter(NotifyBase):
|
|||
# Mark our failure
|
||||
return (False, content)
|
||||
|
||||
except (OSError, IOError) as e:
|
||||
self.logger.warning(
|
||||
'An I/O error occurred while handling {}.'.format(
|
||||
payload.name if isinstance(payload, AttachBase)
|
||||
else payload))
|
||||
self.logger.debug('I/O Exception: %s' % str(e))
|
||||
return (False, content)
|
||||
|
||||
finally:
|
||||
# Close our file (if it's open) stored in the second element
|
||||
# of our files tuple (index 1)
|
||||
if files:
|
||||
files['media'][1].close()
|
||||
|
||||
return (True, content)
|
||||
|
||||
@property
|
||||
|
@ -581,6 +798,8 @@ class NotifyTwitter(NotifyBase):
|
|||
# Define any URL parameters
|
||||
params = {
|
||||
'mode': self.mode,
|
||||
'batch': 'yes' if self.batch else 'no',
|
||||
'cache': 'yes' if self.cache else 'no',
|
||||
}
|
||||
|
||||
# Extend our parameters
|
||||
|
@ -653,10 +872,16 @@ class NotifyTwitter(NotifyBase):
|
|||
# Store any remaining items as potential targets
|
||||
results['targets'].extend(tokens[3:])
|
||||
|
||||
# Get Cache Flag (reduces lookup hits)
|
||||
if 'cache' in results['qsd'] and len(results['qsd']['cache']):
|
||||
results['cache'] = \
|
||||
parse_bool(results['qsd']['cache'], True)
|
||||
|
||||
# Get Batch Mode Flag
|
||||
results['batch'] = \
|
||||
parse_bool(results['qsd'].get(
|
||||
'batch', NotifyTwitter.template_args['batch']['default']))
|
||||
|
||||
# The 'to' makes it easier to use yaml configuration
|
||||
if 'to' in results['qsd'] and len(results['qsd']['to']):
|
||||
results['targets'] += \
|
||||
|
|
|
@ -35,6 +35,16 @@ from ..common import NotifyType
|
|||
from ..AppriseLocale import gettext_lazy as _
|
||||
|
||||
|
||||
# Defines the method to send the notification
|
||||
METHODS = (
|
||||
'POST',
|
||||
'GET',
|
||||
'DELETE',
|
||||
'PUT',
|
||||
'HEAD'
|
||||
)
|
||||
|
||||
|
||||
class NotifyXML(NotifyBase):
|
||||
"""
|
||||
A wrapper for XML Notifications
|
||||
|
@ -98,6 +108,17 @@ class NotifyXML(NotifyBase):
|
|||
'type': 'string',
|
||||
'private': True,
|
||||
},
|
||||
|
||||
})
|
||||
|
||||
# Define our template arguments
|
||||
template_args = dict(NotifyBase.template_args, **{
|
||||
'method': {
|
||||
'name': _('Fetch Method'),
|
||||
'type': 'choice:string',
|
||||
'values': METHODS,
|
||||
'default': METHODS[0],
|
||||
},
|
||||
})
|
||||
|
||||
# Define any kwargs we're using
|
||||
|
@ -106,9 +127,13 @@ class NotifyXML(NotifyBase):
|
|||
'name': _('HTTP Header'),
|
||||
'prefix': '+',
|
||||
},
|
||||
'payload': {
|
||||
'name': _('Payload Extras'),
|
||||
'prefix': ':',
|
||||
},
|
||||
}
|
||||
|
||||
def __init__(self, headers=None, **kwargs):
|
||||
def __init__(self, headers=None, method=None, payload=None, **kwargs):
|
||||
"""
|
||||
Initialize XML Object
|
||||
|
||||
|
@ -124,25 +149,43 @@ class NotifyXML(NotifyBase):
|
|||
xmlns:xsd="http://www.w3.org/2001/XMLSchema"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
|
||||
<soapenv:Body>
|
||||
<Notification xmlns:xsi="{XSD_URL}">
|
||||
<Version>{XSD_VER}</Version>
|
||||
<Subject>{SUBJECT}</Subject>
|
||||
<MessageType>{MESSAGE_TYPE}</MessageType>
|
||||
<Message>{MESSAGE}</Message>
|
||||
{ATTACHMENTS}
|
||||
<Notification xmlns:xsi="{{XSD_URL}}">
|
||||
{{CORE}}
|
||||
{{ATTACHMENTS}}
|
||||
</Notification>
|
||||
</soapenv:Body>
|
||||
</soapenv:Envelope>"""
|
||||
|
||||
self.fullpath = kwargs.get('fullpath')
|
||||
if not isinstance(self.fullpath, six.string_types):
|
||||
self.fullpath = '/'
|
||||
self.fullpath = ''
|
||||
|
||||
self.method = self.template_args['method']['default'] \
|
||||
if not isinstance(method, six.string_types) else method.upper()
|
||||
|
||||
if self.method not in METHODS:
|
||||
msg = 'The method specified ({}) is invalid.'.format(method)
|
||||
self.logger.warning(msg)
|
||||
raise TypeError(msg)
|
||||
|
||||
self.headers = {}
|
||||
if headers:
|
||||
# Store our extra headers
|
||||
self.headers.update(headers)
|
||||
|
||||
self.payload_extras = {}
|
||||
if payload:
|
||||
# Store our extra payload entries (but tidy them up since they will
|
||||
# become XML Keys (they can't contain certain characters
|
||||
for k, v in payload.items():
|
||||
key = re.sub(r'[^A-Za-z0-9_-]*', '', k)
|
||||
if not key:
|
||||
self.logger.warning(
|
||||
'Ignoring invalid XML Stanza element name({})'
|
||||
.format(k))
|
||||
continue
|
||||
self.payload_extras[key] = v
|
||||
|
||||
return
|
||||
|
||||
def url(self, privacy=False, *args, **kwargs):
|
||||
|
@ -150,12 +193,21 @@ class NotifyXML(NotifyBase):
|
|||
Returns the URL built dynamically based on specified arguments.
|
||||
"""
|
||||
|
||||
# Store our defined headers into our URL parameters
|
||||
params = {'+{}'.format(k): v for k, v in self.headers.items()}
|
||||
# Define any URL parameters
|
||||
params = {
|
||||
'method': self.method,
|
||||
}
|
||||
|
||||
# Extend our parameters
|
||||
params.update(self.url_parameters(privacy=privacy, *args, **kwargs))
|
||||
|
||||
# Append our headers into our parameters
|
||||
params.update({'+{}'.format(k): v for k, v in self.headers.items()})
|
||||
|
||||
# Append our payload extra's into our parameters
|
||||
params.update(
|
||||
{':{}'.format(k): v for k, v in self.payload_extras.items()})
|
||||
|
||||
# Determine Authentication
|
||||
auth = ''
|
||||
if self.user and self.password:
|
||||
|
@ -171,14 +223,15 @@ class NotifyXML(NotifyBase):
|
|||
|
||||
default_port = 443 if self.secure else 80
|
||||
|
||||
return '{schema}://{auth}{hostname}{port}{fullpath}/?{params}'.format(
|
||||
return '{schema}://{auth}{hostname}{port}{fullpath}?{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),
|
||||
fullpath=NotifyXML.quote(self.fullpath, safe='/'),
|
||||
fullpath=NotifyXML.quote(self.fullpath, safe='/')
|
||||
if self.fullpath else '/',
|
||||
params=NotifyXML.urlencode(params),
|
||||
)
|
||||
|
||||
|
@ -200,7 +253,24 @@ class NotifyXML(NotifyBase):
|
|||
# Our XML Attachmement subsitution
|
||||
xml_attachments = ''
|
||||
|
||||
# Track our potential attachments
|
||||
# Our Payload Base
|
||||
payload_base = {
|
||||
'Version': self.xsd_ver,
|
||||
'Subject': NotifyXML.escape_html(title, whitespace=False),
|
||||
'MessageType': NotifyXML.escape_html(
|
||||
notify_type, whitespace=False),
|
||||
'Message': NotifyXML.escape_html(body, whitespace=False),
|
||||
}
|
||||
|
||||
# Apply our payload extras
|
||||
payload_base.update(
|
||||
{k: NotifyXML.escape_html(v, whitespace=False)
|
||||
for k, v in self.payload_extras.items()})
|
||||
|
||||
# Base Entres
|
||||
xml_base = ''.join(
|
||||
['<{}>{}</{}>'.format(k, v, k) for k, v in payload_base.items()])
|
||||
|
||||
attachments = []
|
||||
if attach:
|
||||
for attachment in attach:
|
||||
|
@ -239,13 +309,9 @@ class NotifyXML(NotifyBase):
|
|||
''.join(attachments) + '</Attachments>'
|
||||
|
||||
re_map = {
|
||||
'{XSD_VER}': self.xsd_ver,
|
||||
'{XSD_URL}': self.xsd_url.format(version=self.xsd_ver),
|
||||
'{MESSAGE_TYPE}': NotifyXML.escape_html(
|
||||
notify_type, whitespace=False),
|
||||
'{SUBJECT}': NotifyXML.escape_html(title, whitespace=False),
|
||||
'{MESSAGE}': NotifyXML.escape_html(body, whitespace=False),
|
||||
'{ATTACHMENTS}': xml_attachments,
|
||||
'{{XSD_URL}}': self.xsd_url.format(version=self.xsd_ver),
|
||||
'{{ATTACHMENTS}}': xml_attachments,
|
||||
'{{CORE}}': xml_base,
|
||||
}
|
||||
|
||||
# Iterate over above list and store content accordingly
|
||||
|
@ -277,8 +343,23 @@ class NotifyXML(NotifyBase):
|
|||
# Always call throttle before any remote server i/o is made
|
||||
self.throttle()
|
||||
|
||||
if self.method == 'GET':
|
||||
method = requests.get
|
||||
|
||||
elif self.method == 'PUT':
|
||||
method = requests.put
|
||||
|
||||
elif self.method == 'DELETE':
|
||||
method = requests.delete
|
||||
|
||||
elif self.method == 'HEAD':
|
||||
method = requests.head
|
||||
|
||||
else: # POST
|
||||
method = requests.post
|
||||
|
||||
try:
|
||||
r = requests.post(
|
||||
r = method(
|
||||
url,
|
||||
data=payload,
|
||||
headers=headers,
|
||||
|
@ -286,17 +367,17 @@ class NotifyXML(NotifyBase):
|
|||
verify=self.verify_certificate,
|
||||
timeout=self.request_timeout,
|
||||
)
|
||||
if r.status_code != requests.codes.ok:
|
||||
if r.status_code < 200 or r.status_code >= 300:
|
||||
# We had a problem
|
||||
status_str = \
|
||||
NotifyXML.http_response_code_lookup(r.status_code)
|
||||
|
||||
self.logger.warning(
|
||||
'Failed to send XML notification: '
|
||||
'{}{}error={}.'.format(
|
||||
status_str,
|
||||
', ' if status_str else '',
|
||||
r.status_code))
|
||||
'Failed to send JSON %s notification: %s%serror=%s.',
|
||||
self.method,
|
||||
status_str,
|
||||
', ' if status_str else '',
|
||||
str(r.status_code))
|
||||
|
||||
self.logger.debug('Response Details:\r\n{}'.format(r.content))
|
||||
|
||||
|
@ -304,7 +385,7 @@ class NotifyXML(NotifyBase):
|
|||
return False
|
||||
|
||||
else:
|
||||
self.logger.info('Sent XML notification.')
|
||||
self.logger.info('Sent XML %s notification.', self.method)
|
||||
|
||||
except requests.RequestException as e:
|
||||
self.logger.warning(
|
||||
|
@ -329,6 +410,10 @@ class NotifyXML(NotifyBase):
|
|||
# We're done early as we couldn't load the results
|
||||
return results
|
||||
|
||||
# store any additional payload extra's defined
|
||||
results['payload'] = {NotifyXML.unquote(x): NotifyXML.unquote(y)
|
||||
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+']
|
||||
|
@ -342,4 +427,8 @@ class NotifyXML(NotifyBase):
|
|||
results['headers'] = {NotifyXML.unquote(x): NotifyXML.unquote(y)
|
||||
for x, y in results['headers'].items()}
|
||||
|
||||
# Set method if not otherwise set
|
||||
if 'method' in results['qsd'] and len(results['qsd']['method']):
|
||||
results['method'] = NotifyXML.unquote(results['qsd']['method'])
|
||||
|
||||
return results
|
||||
|
|
|
@ -28,8 +28,11 @@ import six
|
|||
import json
|
||||
import contextlib
|
||||
import os
|
||||
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
|
||||
|
||||
try:
|
||||
# Python 2.7
|
||||
|
@ -133,6 +136,17 @@ IS_PHONE_NO = re.compile(r'^\+?(?P<phone>[0-9\s)(+-]+)\s*$')
|
|||
PHONE_NO_DETECTION_RE = re.compile(
|
||||
r'\s*([+(\s]*[0-9][0-9()\s-]+[0-9])(?=$|[\s,+(]+[0-9])', re.I)
|
||||
|
||||
# A simple verification check to make sure the content specified
|
||||
# rougly conforms to a ham radio call sign before we parse it further
|
||||
IS_CALL_SIGN = re.compile(
|
||||
r'^(?P<callsign>[a-z0-9]{2,3}[0-9][a-z0-9]{3})'
|
||||
r'(?P<ssid>-[a-z0-9]{1,2})?\s*$', re.I)
|
||||
|
||||
# Regular expression used to destinguish between multiple ham radio call signs
|
||||
CALL_SIGN_DETECTION_RE = re.compile(
|
||||
r'\s*([a-z0-9]{2,3}[0-9][a-z0-9]{3}(?:-[a-z0-9]{1,2})?)'
|
||||
r'(?=$|[\s,]+[a-z0-9]{4,6})', re.I)
|
||||
|
||||
# Regular expression used to destinguish between multiple URLs
|
||||
URL_DETECTION_RE = re.compile(
|
||||
r'([a-z0-9]+?:\/\/.*?)(?=$|[\s,]+[a-z0-9]{2,9}?:\/\/)', re.I)
|
||||
|
@ -372,6 +386,37 @@ def is_phone_no(phone, min_len=11):
|
|||
}
|
||||
|
||||
|
||||
def is_call_sign(callsign):
|
||||
"""Determine if the specified entry is a ham radio call sign
|
||||
|
||||
Args:
|
||||
callsign (str): The string you want to check.
|
||||
|
||||
Returns:
|
||||
bool: Returns False if the address specified is not a phone number
|
||||
"""
|
||||
|
||||
try:
|
||||
result = IS_CALL_SIGN.match(callsign)
|
||||
if not result:
|
||||
# not parseable content as it does not even conform closely to a
|
||||
# callsign
|
||||
return False
|
||||
|
||||
except TypeError:
|
||||
# not parseable content
|
||||
return False
|
||||
|
||||
ssid = result.group('ssid')
|
||||
return {
|
||||
# always treat call signs as uppercase content
|
||||
'callsign': result.group('callsign').upper(),
|
||||
# Prevent the storing of the None keyword in the event the SSID was
|
||||
# not detected
|
||||
'ssid': ssid if ssid else '',
|
||||
}
|
||||
|
||||
|
||||
def is_email(address):
|
||||
"""Determine if the specified entry is an email address
|
||||
|
||||
|
@ -523,7 +568,7 @@ def parse_qsd(qs):
|
|||
return result
|
||||
|
||||
|
||||
def parse_url(url, default_schema='http', verify_host=True):
|
||||
def parse_url(url, default_schema='http', verify_host=True, strict_port=False):
|
||||
"""A function that greatly simplifies the parsing of a url
|
||||
specified by the end user.
|
||||
|
||||
|
@ -655,13 +700,29 @@ def parse_url(url, default_schema='http', verify_host=True):
|
|||
# and it's already assigned
|
||||
pass
|
||||
|
||||
# Max port is 65535 so (1,5 digits)
|
||||
match = re.search(
|
||||
r'^(?P<host>.+):(?P<port>[1-9][0-9]{0,4})$', result['host'])
|
||||
if match:
|
||||
# Port Parsing
|
||||
pmatch = re.search(
|
||||
r'^(?P<host>(\[[0-9a-f:]+\]|[^:]+)):(?P<port>[^:]*)$',
|
||||
result['host'])
|
||||
|
||||
if pmatch:
|
||||
# Separate our port from our hostname (if port is detected)
|
||||
result['host'] = match.group('host')
|
||||
result['port'] = int(match.group('port'))
|
||||
result['host'] = pmatch.group('host')
|
||||
try:
|
||||
# If we're dealing with an integer, go ahead and convert it
|
||||
# otherwise return an 'x' which will raise a ValueError
|
||||
#
|
||||
# This small extra check allows us to treat floats/doubles
|
||||
# as strings. Hence a value like '4.2' won't be converted to a 4
|
||||
# (and the .2 lost)
|
||||
result['port'] = int(
|
||||
pmatch.group('port')
|
||||
if re.search(r'[0-9]', pmatch.group('port')) else 'x')
|
||||
|
||||
except ValueError:
|
||||
if verify_host:
|
||||
# Invalid Host Specified
|
||||
return None
|
||||
|
||||
if verify_host:
|
||||
# Verify and Validate our hostname
|
||||
|
@ -671,6 +732,26 @@ def parse_url(url, default_schema='http', verify_host=True):
|
|||
# some indication as to what went wrong
|
||||
return None
|
||||
|
||||
# Max port is 65535 and min is 1
|
||||
if isinstance(result['port'], int) and not ((
|
||||
not strict_port or (
|
||||
strict_port and
|
||||
result['port'] > 0 and result['port'] <= 65535))):
|
||||
|
||||
# An invalid port was specified
|
||||
return None
|
||||
|
||||
elif pmatch and not isinstance(result['port'], int):
|
||||
if strict_port:
|
||||
# Store port
|
||||
result['port'] = pmatch.group('port').strip()
|
||||
|
||||
else:
|
||||
# Fall back
|
||||
result['port'] = None
|
||||
result['host'] = '{}:{}'.format(
|
||||
pmatch.group('host'), pmatch.group('port'))
|
||||
|
||||
# Re-assemble cleaned up version of the url
|
||||
result['url'] = '%s://' % result['schema']
|
||||
if isinstance(result['user'], six.string_types):
|
||||
|
@ -683,8 +764,12 @@ def parse_url(url, default_schema='http', verify_host=True):
|
|||
result['url'] += '@'
|
||||
result['url'] += result['host']
|
||||
|
||||
if result['port']:
|
||||
result['url'] += ':%d' % result['port']
|
||||
if result['port'] is not None:
|
||||
try:
|
||||
result['url'] += ':%d' % result['port']
|
||||
|
||||
except TypeError:
|
||||
result['url'] += ':%s' % result['port']
|
||||
|
||||
if result['fullpath']:
|
||||
result['url'] += result['fullpath']
|
||||
|
@ -766,6 +851,43 @@ def parse_phone_no(*args, **kwargs):
|
|||
return result
|
||||
|
||||
|
||||
def parse_call_sign(*args, **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:
|
||||
_result = CALL_SIGN_DETECTION_RE.findall(arg)
|
||||
if _result:
|
||||
result += _result
|
||||
|
||||
elif not _result and store_unparseable:
|
||||
# we had content passed into us that was lost because it was
|
||||
# so poorly formatted that it didn't even come close to
|
||||
# meeting the regular expression we defined. We intentially
|
||||
# keep it as part of our result set so that parsing done
|
||||
# at a higher level can at least report this to the end user
|
||||
# and hopefully give them some indication as to what they
|
||||
# may have done wrong.
|
||||
result += \
|
||||
[x for x in filter(bool, re.split(STRING_DELIMITERS, arg))]
|
||||
|
||||
elif isinstance(arg, (set, list, tuple)):
|
||||
# Use recursion to handle the list of call signs
|
||||
result += parse_call_sign(
|
||||
*arg, store_unparseable=store_unparseable)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def parse_emails(*args, **kwargs):
|
||||
"""
|
||||
Takes a string containing emails separated by comma's and/or spaces and
|
||||
|
@ -876,7 +998,8 @@ def parse_list(*args):
|
|||
return sorted([x for x in filter(bool, list(set(result)))])
|
||||
|
||||
|
||||
def is_exclusive_match(logic, data, match_all='all'):
|
||||
def is_exclusive_match(logic, data, match_all=MATCH_ALL_TAG,
|
||||
match_always=MATCH_ALWAYS_TAG):
|
||||
"""
|
||||
|
||||
The data variable should always be a set of strings that the logic can be
|
||||
|
@ -892,6 +1015,9 @@ def is_exclusive_match(logic, data, match_all='all'):
|
|||
logic=['tagA', 'tagB'] = tagA or tagB
|
||||
logic=[('tagA', 'tagC'), 'tagB'] = (tagA and tagC) or tagB
|
||||
logic=[('tagB', 'tagC')] = tagB and tagC
|
||||
|
||||
If `match_always` is not set to None, then its value is added as an 'or'
|
||||
to all specified logic searches.
|
||||
"""
|
||||
|
||||
if isinstance(logic, six.string_types):
|
||||
|
@ -907,6 +1033,10 @@ def is_exclusive_match(logic, data, match_all='all'):
|
|||
# garbage input
|
||||
return False
|
||||
|
||||
if match_always:
|
||||
# Add our match_always to our logic searching if secified
|
||||
logic = chain(logic, [match_always])
|
||||
|
||||
# Track what we match against; but by default we do not match
|
||||
# against anything
|
||||
matched = False
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
# Bazarr dependencies
|
||||
argparse==1.4.0
|
||||
apprise==0.9.6
|
||||
apprise==0.9.8.3
|
||||
apscheduler==3.8.1
|
||||
charamel==1.0.0
|
||||
deep-translator==1.8.3
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue