diff options
Diffstat (limited to 'libs/apprise')
33 files changed, 5057 insertions, 1344 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'<': '<', - } - - # 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 Binary files differnew file mode 100644 index 000000000..925d178f0 --- /dev/null +++ b/libs/apprise/i18n/en/LC_MESSAGES/apprise.mo 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 ( ) and tabs ( ) aren't supported + # See https://core.telegram.org/bots/api#html-style + re.compile(r'\ ?', re.I): ' ', + + # Tabs become 3 spaces + re.compile(r'\ ?', re.I): ' ', + + # Some characters get re-escaped by the Telegram upstream + # service so we need to convert these back, + re.compile(r'\'?', re.I): '\'', + re.compile(r'\"?', re.I): '"', + } + # Define our template tokens template_tokens = dict(NotifyBase.template_tokens, **{ 'bot_token': { @@ -483,15 +532,15 @@ class NotifyTelegram(NotifyBase): # "text":"/start", # "entities":[{"offset":0,"length":6,"type":"bot_command"}]}}] - if 'ok' in response and response['ok'] is True \ - and 'result' in response and len(response['result']): - entry = response['result'][0] - _id = entry['message']['from'].get('id', 0) - _user = entry['message']['from'].get('first_name') - self.logger.info('Detected Telegram user %s (userid=%d)' % ( - _user, _id)) - # Return our detected userid - return _id + if response.get('ok', False): + for entry in response.get('result', []): + if 'message' in entry and 'from' in entry['message']: + _id = entry['message']['from'].get('id', 0) + _user = entry['message']['from'].get('first_name') + self.logger.info( + 'Detected Telegram user %s (userid=%d)' % (_user, _id)) + # Return our detected userid + return _id self.logger.warning( 'Failed to detect a Telegram user; ' @@ -499,7 +548,7 @@ class NotifyTelegram(NotifyBase): return 0 def send(self, body, title='', notify_type=NotifyType.INFO, attach=None, - **kwargs): + body_format=None, **kwargs): """ Perform Telegram Notification """ @@ -538,87 +587,47 @@ class NotifyTelegram(NotifyBase): 'disable_web_page_preview': not self.preview, } - # Prepare Email Message + # Prepare Message Body if self.notify_format == NotifyFormat.MARKDOWN: payload['parse_mode'] = 'MARKDOWN' - payload['text'] = '{}{}'.format( - '{}\r\n'.format(title) if title else '', - body, - ) + payload['text'] = body - else: # HTML or TEXT + else: # HTML # Use Telegram's HTML mode payload['parse_mode'] = 'HTML' - - # Telegram's HTML support doesn't like having HTML escaped - # characters passed into it. to handle this situation, we need to - # search the body for these sequences and convert them to the - # output the user expected - telegram_escape_html_dict = { - # HTML Spaces ( ) and tabs ( ) aren't supported - # See https://core.telegram.org/bots/api#html-style - r'nbsp': ' ', - - # 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 ' 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'>': '>', - r'<': '<', - } - - # Create a regular expression from the dictionary keys - text_regex = re.compile("(%s)" % "|".join( - map(re.escape, telegram_escape_text_dict.keys())).lower(), - re.I) - - # For each match, look-up corresponding value in dictionary - body = text_regex.sub( # pragma: no branch - lambda mo: telegram_escape_text_dict[ - mo.string[mo.start():mo.end()]], body) - - if title: - # For each match, look-up corresponding value in dictionary - title = text_regex.sub( # pragma: no branch - lambda mo: telegram_escape_text_dict[ - mo.string[mo.start():mo.end()]], title) - - payload['text'] = '{}{}'.format( - '<b>{}</b>\r\n'.format(title) if title else '', - body, - ) + 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'>': '>', + # r'<': '<', + # r'\&': '&', + # } + + # # Create a regular expression from the dictionary keys + # text_regex = re.compile("(%s)" % "|".join( + # map(re.escape, telegram_escape_text_dict.keys())).lower(), + # re.I) + + # # For each match, look-up corresponding value in dictionary + # body = text_regex.sub( # pragma: no branch + # lambda mo: telegram_escape_text_dict[ + # mo.string[mo.start():mo.end()]], body) + + # # 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 |