summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
-rw-r--r--libs/apprise/Apprise.py212
-rw-r--r--libs/apprise/AppriseAsset.py20
-rw-r--r--libs/apprise/AppriseConfig.py14
-rw-r--r--libs/apprise/__init__.py6
-rw-r--r--libs/apprise/common.py4
-rw-r--r--libs/apprise/conversion.py210
-rw-r--r--libs/apprise/i18n/apprise.pot660
-rw-r--r--libs/apprise/i18n/en/LC_MESSAGES/apprise.mobin0 -> 455 bytes
-rw-r--r--libs/apprise/i18n/en/LC_MESSAGES/apprise.po293
-rw-r--r--libs/apprise/plugins/NotifyBase.py30
-rw-r--r--libs/apprise/plugins/NotifyDapnet.py396
-rw-r--r--libs/apprise/plugins/NotifyEmail.py37
-rw-r--r--libs/apprise/plugins/NotifyFCM/__init__.py181
-rw-r--r--libs/apprise/plugins/NotifyFCM/color.py127
-rw-r--r--libs/apprise/plugins/NotifyFCM/common.py42
-rw-r--r--libs/apprise/plugins/NotifyFCM/priority.py255
-rw-r--r--libs/apprise/plugins/NotifyForm.py393
-rw-r--r--libs/apprise/plugins/NotifyGotify.py7
-rw-r--r--libs/apprise/plugins/NotifyJSON.py102
-rw-r--r--libs/apprise/plugins/NotifyMacOSX.py18
-rw-r--r--libs/apprise/plugins/NotifyMatrix.py62
-rw-r--r--libs/apprise/plugins/NotifyNextcloudTalk.py281
-rw-r--r--libs/apprise/plugins/NotifyNtfy.py678
-rw-r--r--libs/apprise/plugins/NotifyReddit.py6
-rw-r--r--libs/apprise/plugins/NotifySES.py950
-rw-r--r--libs/apprise/plugins/NotifySNS.py45
-rw-r--r--libs/apprise/plugins/NotifyServerChan.py173
-rw-r--r--libs/apprise/plugins/NotifySignalAPI.py400
-rw-r--r--libs/apprise/plugins/NotifySlack.py4
-rw-r--r--libs/apprise/plugins/NotifyTelegram.py177
-rw-r--r--libs/apprise/plugins/NotifyTwitter.py323
-rw-r--r--libs/apprise/plugins/NotifyXML.py145
-rw-r--r--libs/apprise/utils.py150
-rw-r--r--libs/version.txt2
34 files changed, 5058 insertions, 1345 deletions
diff --git a/libs/apprise/Apprise.py b/libs/apprise/Apprise.py
index 8930b2a77..30c936532 100644
--- a/libs/apprise/Apprise.py
+++ b/libs/apprise/Apprise.py
@@ -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")
-
- 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')
+ msg = "No message content specified to deliver"
+ logger.error(msg)
+ raise TypeError(msg)
- if isinstance(body, unicode): # noqa: F821
- body = body.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 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:
-
- # Apply Markdown
- conversion_map[server.notify_format] = markdown(body)
-
- elif body_format == NotifyFormat.TEXT and \
- server.notify_format == NotifyFormat.HTML:
-
- # Basic TEXT to HTML format map; supports keys only
- re_map = {
- # Support Ampersand
- r'&': '&',
-
- # Spaces to   for formatting purposes since
- # multiple spaces are treated as one an this may
- # not be the callers intention
- r' ': ' ',
-
- # Tab support
- r'\t': '   ',
-
- # Greater than and Less than Characters
- r'>': '>',
- r'<': '&lt;',
- }
-
- # Compile our map
- re_table = re.compile(
- r'(' + '|'.join(
- map(re.escape, re_map.keys())) + r')',
- re.IGNORECASE,
- )
-
- # Execute our map against our body in addition to
- # swapping out new lines and replacing them with <br/>
- conversion_map[server.notify_format] = \
- re.sub(r'\r*\n', '<br/>\r\n',
- re_table.sub(
- lambda x: re_map[x.group()], body))
+ 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)
+
+ # Prepare our title
+ conversion_title_map[server.notify_format] = \
+ '' if not title else title
+
+ # Tidy Title IF required (hence it will become part of the
+ # body)
+ if server.title_maxlen <= 0 and \
+ conversion_title_map[server.notify_format]:
+
+ conversion_title_map[server.notify_format] = \
+ convert_between(
+ body_format, server.notify_format,
+ content=conversion_title_map[server.notify_format])
+
+ if interpret_escapes:
+ #
+ # Escape our content
+ #
- 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):
diff --git a/libs/apprise/AppriseAsset.py b/libs/apprise/AppriseAsset.py
index e2e95b4a7..cd7324837 100644
--- a/libs/apprise/AppriseAsset.py
+++ b/libs/apprise/AppriseAsset.py
@@ -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
diff --git a/libs/apprise/AppriseConfig.py b/libs/apprise/AppriseConfig.py
index fa5e6fba9..64d01738c 100644
--- a/libs/apprise/AppriseConfig.py
+++ b/libs/apprise/AppriseConfig.py
@@ -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())
diff --git a/libs/apprise/__init__.py b/libs/apprise/__init__.py
index 090261086..ee031d93f 100644
--- a/libs/apprise/__init__.py
+++ b/libs/apprise/__init__.py
@@ -1,6 +1,6 @@
# -*- coding: utf-8 -*-
#
-# Copyright (C) 2021 Chris Caron <[email protected]>
+# Copyright (C) 2022 Chris Caron <[email protected]>
# 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 <[email protected]>'
+__copywrite__ = 'Copyright (C) 2022 Chris Caron <[email protected]>'
__email__ = '[email protected]'
__status__ = 'Production'
diff --git a/libs/apprise/common.py b/libs/apprise/common.py
index 186bfe1bc..d1f43ada0 100644
--- a/libs/apprise/common.py
+++ b/libs/apprise/common.py
@@ -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'
diff --git a/libs/apprise/conversion.py b/libs/apprise/conversion.py
new file mode 100644
index 000000000..bfd9a644d
--- /dev/null
+++ b/libs/apprise/conversion.py
@@ -0,0 +1,210 @@
+# -*- coding: utf-8 -*-
+#
+# Copyright (C) 2022 Chris Caron <[email protected]>
+# 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)
diff --git a/libs/apprise/i18n/apprise.pot b/libs/apprise/i18n/apprise.pot
deleted file mode 100644
index 274b379c1..000000000
--- a/libs/apprise/i18n/apprise.pot
+++ /dev/null
@@ -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: [email protected]\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 <[email protected]>\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 ""
-
diff --git a/libs/apprise/i18n/en/LC_MESSAGES/apprise.mo b/libs/apprise/i18n/en/LC_MESSAGES/apprise.mo
new file mode 100644
index 000000000..925d178f0
--- /dev/null
+++ b/libs/apprise/i18n/en/LC_MESSAGES/apprise.mo
Binary files differ
diff --git a/libs/apprise/i18n/en/LC_MESSAGES/apprise.po b/libs/apprise/i18n/en/LC_MESSAGES/apprise.po
deleted file mode 100644
index 44451262c..000000000
--- a/libs/apprise/i18n/en/LC_MESSAGES/apprise.po
+++ /dev/null
@@ -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 <[email protected]>, 2019.
-#
-msgid ""
-msgstr ""
-"Project-Id-Version: apprise 0.7.6\n"
-"Report-Msgid-Bugs-To: [email protected]\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 <[email protected]>\n"
-"Language: en\n"
-"Language-Team: en <[email protected]>\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 ""
-
diff --git a/libs/apprise/plugins/NotifyBase.py b/libs/apprise/plugins/NotifyBase.py
index 82c025506..54e897906 100644
--- a/libs/apprise/plugins/NotifyBase.py
+++ b/libs/apprise/plugins/NotifyBase.py
@@ -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)
diff --git a/libs/apprise/plugins/NotifyDapnet.py b/libs/apprise/plugins/NotifyDapnet.py
new file mode 100644
index 000000000..2e0389dbc
--- /dev/null
+++ b/libs/apprise/plugins/NotifyDapnet.py
@@ -0,0 +1,396 @@
+# -*- coding: utf-8 -*-
+#
+# Copyright (C) 2019 Chris Caron <[email protected]>
+# 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
diff --git a/libs/apprise/plugins/NotifyEmail.py b/libs/apprise/plugins/NotifyEmail.py
index 7bd894387..14937b9a3 100644
--- a/libs/apprise/plugins/NotifyEmail.py
+++ b/libs/apprise/plugins/NotifyEmail.py
@@ -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',
diff --git a/libs/apprise/plugins/NotifyFCM/__init__.py b/libs/apprise/plugins/NotifyFCM/__init__.py
index 9269ea3b0..a6f801c8b 100644
--- a/libs/apprise/plugins/NotifyFCM/__init__.py
+++ b/libs/apprise/plugins/NotifyFCM/__init__.py
@@ -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
diff --git a/libs/apprise/plugins/NotifyFCM/color.py b/libs/apprise/plugins/NotifyFCM/color.py
new file mode 100644
index 000000000..a2fcfd662
--- /dev/null
+++ b/libs/apprise/plugins/NotifyFCM/color.py
@@ -0,0 +1,127 @@
+# -*- coding: utf-8 -*-
+#
+# Copyright (C) 2021 Chris Caron <[email protected]>
+# 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
diff --git a/libs/apprise/plugins/NotifyFCM/common.py b/libs/apprise/plugins/NotifyFCM/common.py
new file mode 100644
index 000000000..39765ff0b
--- /dev/null
+++ b/libs/apprise/plugins/NotifyFCM/common.py
@@ -0,0 +1,42 @@
+# -*- coding: utf-8 -*-
+#
+# Copyright (C) 2021 Chris Caron <[email protected]>
+# 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,
+)
diff --git a/libs/apprise/plugins/NotifyFCM/priority.py b/libs/apprise/plugins/NotifyFCM/priority.py
new file mode 100644
index 000000000..7c8ab1ccf
--- /dev/null
+++ b/libs/apprise/plugins/NotifyFCM/priority.py
@@ -0,0 +1,255 @@
+# -*- coding: utf-8 -*-
+#
+# Copyright (C) 2021 Chris Caron <[email protected]>
+# 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
diff --git a/libs/apprise/plugins/NotifyForm.py b/libs/apprise/plugins/NotifyForm.py
new file mode 100644
index 000000000..32ae2c060
--- /dev/null
+++ b/libs/apprise/plugins/NotifyForm.py
@@ -0,0 +1,393 @@
+# -*- coding: utf-8 -*-
+#
+# Copyright (C) 2022 Chris Caron <[email protected]>
+# 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
diff --git a/libs/apprise/plugins/NotifyGotify.py b/libs/apprise/plugins/NotifyGotify.py
index 3b8b17589..d064c07a3 100644
--- a/libs/apprise/plugins/NotifyGotify.py
+++ b/libs/apprise/plugins/NotifyGotify.py
@@ -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,
diff --git a/libs/apprise/plugins/NotifyJSON.py b/libs/apprise/plugins/NotifyJSON.py
index c03670331..c14bbd369 100644
--- a/libs/apprise/plugins/NotifyJSON.py
+++ b/libs/apprise/plugins/NotifyJSON.py
@@ -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
diff --git a/libs/apprise/plugins/NotifyMacOSX.py b/libs/apprise/plugins/NotifyMacOSX.py
index 7c9e289cf..dfd8080f6 100644
--- a/libs/apprise/plugins/NotifyMacOSX.py
+++ b/libs/apprise/plugins/NotifyMacOSX.py
@@ -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
diff --git a/libs/apprise/plugins/NotifyMatrix.py b/libs/apprise/plugins/NotifyMatrix.py
index b2103d995..c739e51e5 100644
--- a/libs/apprise/plugins/NotifyMatrix.py
+++ b/libs/apprise/plugins/NotifyMatrix.py
@@ -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
diff --git a/libs/apprise/plugins/NotifyNextcloudTalk.py b/libs/apprise/plugins/NotifyNextcloudTalk.py
new file mode 100644
index 000000000..1b05eb2bf
--- /dev/null
+++ b/libs/apprise/plugins/NotifyNextcloudTalk.py
@@ -0,0 +1,281 @@
+# -*- coding: utf-8 -*-
+#
+# Copyright (C) 2019 Chris Caron <[email protected]>
+# 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
diff --git a/libs/apprise/plugins/NotifyNtfy.py b/libs/apprise/plugins/NotifyNtfy.py
new file mode 100644
index 000000000..b19cf7a9f
--- /dev/null
+++ b/libs/apprise/plugins/NotifyNtfy.py
@@ -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
diff --git a/libs/apprise/plugins/NotifyReddit.py b/libs/apprise/plugins/NotifyReddit.py
index 2da5da86e..7cd5f6883 100644
--- a/libs/apprise/plugins/NotifyReddit.py
+++ b/libs/apprise/plugins/NotifyReddit.py
@@ -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,
diff --git a/libs/apprise/plugins/NotifySES.py b/libs/apprise/plugins/NotifySES.py
new file mode 100644
index 000000000..462ea5f85
--- /dev/null
+++ b/libs/apprise/plugins/NotifySES.py
@@ -0,0 +1,950 @@
+# -*- coding: utf-8 -*-
+#
+# Copyright (C) 2021 Chris Caron <[email protected]>
+# 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/[email protected]
+#
+# 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
diff --git a/libs/apprise/plugins/NotifySNS.py b/libs/apprise/plugins/NotifySNS.py
index 3cc15a567..8af0847a2 100644
--- a/libs/apprise/plugins/NotifySNS.py
+++ b/libs/apprise/plugins/NotifySNS.py
@@ -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
diff --git a/libs/apprise/plugins/NotifyServerChan.py b/libs/apprise/plugins/NotifyServerChan.py
new file mode 100644
index 000000000..2af40d73f
--- /dev/null
+++ b/libs/apprise/plugins/NotifyServerChan.py
@@ -0,0 +1,173 @@
+# -*- coding: utf-8 -*-
+#
+# Copyright (C) 2020 Chris Caron <[email protected]>
+# 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
diff --git a/libs/apprise/plugins/NotifySignalAPI.py b/libs/apprise/plugins/NotifySignalAPI.py
new file mode 100644
index 000000000..a753215b4
--- /dev/null
+++ b/libs/apprise/plugins/NotifySignalAPI.py
@@ -0,0 +1,400 @@
+# -*- coding: utf-8 -*-
+#
+# Copyright (C) 2020 Chris Caron <[email protected]>
+# 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
diff --git a/libs/apprise/plugins/NotifySlack.py b/libs/apprise/plugins/NotifySlack.py
index ff7907a35..cd861478b 100644
--- a/libs/apprise/plugins/NotifySlack.py
+++ b/libs/apprise/plugins/NotifySlack.py
@@ -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
diff --git a/libs/apprise/plugins/NotifyTelegram.py b/libs/apprise/plugins/NotifyTelegram.py
index 3d9d718ec..23552eb62 100644
--- a/libs/apprise/plugins/NotifyTelegram.py
+++ b/libs/apprise/plugins/NotifyTelegram.py
@@ -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 (&nbsp;) and tabs (&emsp;) aren't supported
+ # See https://core.telegram.org/bots/api#html-style
+ re.compile(r'\&nbsp;?', re.I): ' ',
+
+ # Tabs become 3 spaces
+ re.compile(r'\&emsp;?', re.I): ' ',
+
+ # Some characters get re-escaped by the Telegram upstream
+ # service so we need to convert these back,
+ re.compile(r'\&apos;?', re.I): '\'',
+ re.compile(r'\&quot;?', re.I): '"',
+ }
+
# 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'
-
- # 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 (&nbsp;) and tabs (&emsp;) aren't supported
- # See https://core.telegram.org/bots/api#html-style
- r'nbsp': ' ',
-
- # Tabs become 3 spaces
- r'emsp': ' ',
-
- # Some characters get re-escaped by the Telegram upstream
- # service so we need to convert these back,
- r'apos': '\'',
- r'quot': '"',
- }
-
- # 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)
-
- # 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 &apos; 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)
-
- 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'>': '&gt;',
- r'<': '&lt;',
- }
-
- # 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,
- )
+ for r, v in self.__telegram_escape_html_dict.items():
+ body = r.sub(v, body, re.I)
+
+ # Prepare our payload based on HTML or TEXT
+ payload['text'] = body
+
+ # else: # self.notify_format == NotifyFormat.TEXT:
+ # # Use Telegram's HTML mode
+ # payload['parse_mode'] = 'HTML'
+
+ # # Further html escaping required...
+ # telegram_escape_text_dict = {
+ # # We need to escape characters that conflict with html
+ # # entity blocks (< and >) when displaying text
+ # r'>': '&gt;',
+ # r'<': '&lt;',
+ # r'\&': '&amp;',
+ # }
+
+ # # Create a regular expression from the dictionary keys
+ # text_regex = re.compile("(%s)" % "|".join(
+ # map(re.escape, telegram_escape_text_dict.keys())).lower(),
+ # re.I)
+
+ # # For each match, look-up corresponding value in dictionary
+ # body = text_regex.sub( # pragma: no branch
+ # lambda mo: telegram_escape_text_dict[
+ # mo.string[mo.start():mo.end()]], body)
+
+ # # prepare our payload based on HTML or TEXT
+ # payload['text'] = body
# Create a copy of the chat_ids list
targets = list(self.targets)
diff --git a/libs/apprise/plugins/NotifyTwitter.py b/libs/apprise/plugins/NotifyTwitter.py
index 437cd1e83..8483fad65 100644
--- a/libs/apprise/plugins/NotifyTwitter.py
+++ b/libs/apprise/plugins/NotifyTwitter.py
@@ -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
-
- # 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 Twitter DM notification to @{}.'.format(screen_name))
+ payloads = []
+ if not attachments:
+ payloads.append(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)
+
+ 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
+
+ # 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 = \
@@ -533,15 +745,6 @@ class NotifyTwitter(NotifyBase):
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 = \
int(r.headers.get('x-rate-limit-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'] += \
diff --git a/libs/apprise/plugins/NotifyXML.py b/libs/apprise/plugins/NotifyXML.py
index 39438fada..1f73f898d 100644
--- a/libs/apprise/plugins/NotifyXML.py
+++ b/libs/apprise/plugins/NotifyXML.py
@@ -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
diff --git a/libs/apprise/utils.py b/libs/apprise/utils.py
index 27b263c34..6bf6df23c 100644
--- a/libs/apprise/utils.py
+++ b/libs/apprise/utils.py
@@ -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
diff --git a/libs/version.txt b/libs/version.txt
index ca5bced31..7e5183e01 100644
--- a/libs/version.txt
+++ b/libs/version.txt
@@ -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