Updated apprise to the latest version. #1834

pull/1836/head v1.0.5-beta.6
morpheus65535 3 years ago
parent fcd67c1fb0
commit 1dff555fc8

@ -23,14 +23,13 @@
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
# THE SOFTWARE.
import re
import os
import six
from markdown import markdown
from itertools import chain
from .common import NotifyType
from .common import NotifyFormat
from .common import MATCH_ALL_TAG
from .common import MATCH_ALWAYS_TAG
from .conversion import convert_between
from .utils import is_exclusive_match
from .utils import parse_list
from .utils import parse_urls
@ -44,6 +43,7 @@ from .AppriseLocale import AppriseLocale
from .config.ConfigBase import ConfigBase
from .plugins.NotifyBase import NotifyBase
from . import plugins
from . import __version__
@ -305,7 +305,7 @@ class Apprise(object):
"""
self.servers[:] = []
def find(self, tag=MATCH_ALL_TAG):
def find(self, tag=MATCH_ALL_TAG, match_always=True):
"""
Returns an list of all servers matching against the tag specified.
@ -321,6 +321,10 @@ class Apprise(object):
# tag=[('tagA', 'tagC'), 'tagB'] = (tagA and tagC) or tagB
# tag=[('tagB', 'tagC')] = tagB and tagC
# A match_always flag allows us to pick up on our 'any' keyword
# and notify these services under all circumstances
match_always = MATCH_ALWAYS_TAG if match_always else None
# Iterate over our loaded plugins
for entry in self.servers:
@ -334,13 +338,14 @@ class Apprise(object):
for server in servers:
# Apply our tag matching based on our defined logic
if is_exclusive_match(
logic=tag, data=server.tags, match_all=MATCH_ALL_TAG):
logic=tag, data=server.tags, match_all=MATCH_ALL_TAG,
match_always=match_always):
yield server
return
def notify(self, body, title='', notify_type=NotifyType.INFO,
body_format=None, tag=MATCH_ALL_TAG, attach=None,
interpret_escapes=None):
body_format=None, tag=MATCH_ALL_TAG, match_always=True,
attach=None, interpret_escapes=None):
"""
Send a notification to all of the plugins previously loaded.
@ -370,7 +375,7 @@ class Apprise(object):
self.async_notify(
body, title,
notify_type=notify_type, body_format=body_format,
tag=tag, attach=attach,
tag=tag, match_always=match_always, attach=attach,
interpret_escapes=interpret_escapes,
),
debug=self.debug
@ -468,8 +473,8 @@ class Apprise(object):
return py3compat.asyncio.toasyncwrap(status)
def _notifyall(self, handler, body, title='', notify_type=NotifyType.INFO,
body_format=None, tag=MATCH_ALL_TAG, attach=None,
interpret_escapes=None):
body_format=None, tag=MATCH_ALL_TAG, match_always=True,
attach=None, interpret_escapes=None):
"""
Creates notifications for all of the plugins loaded.
@ -480,22 +485,43 @@ class Apprise(object):
if len(self) == 0:
# Nothing to notify
raise TypeError("No service(s) to notify")
msg = "There are service(s) to notify"
logger.error(msg)
raise TypeError(msg)
if not (title or body):
raise TypeError("No message content specified to deliver")
if six.PY2:
# Python 2.7.x Unicode Character Handling
# Ensure we're working with utf-8
if isinstance(title, unicode): # noqa: F821
title = title.encode('utf-8')
msg = "No message content specified to deliver"
logger.error(msg)
raise TypeError(msg)
if isinstance(body, unicode): # noqa: F821
body = body.encode('utf-8')
try:
if six.PY2:
# Python 2.7 encoding support isn't the greatest, so we try
# to ensure that we're ALWAYS dealing with unicode characters
# prior to entrying the next part. This is especially required
# for Markdown support
if title and isinstance(title, str): # noqa: F821
title = title.decode(self.asset.encoding)
if body and isinstance(body, str): # noqa: F821
body = body.decode(self.asset.encoding)
else: # Python 3+
if title and isinstance(title, bytes): # noqa: F821
title = title.decode(self.asset.encoding)
if body and isinstance(body, bytes): # noqa: F821
body = body.decode(self.asset.encoding)
except UnicodeDecodeError:
msg = 'The content passed into Apprise was not of encoding ' \
'type: {}'.format(self.asset.encoding)
logger.error(msg)
raise TypeError(msg)
# Tracks conversions
conversion_map = dict()
conversion_body_map = dict()
conversion_title_map = dict()
# Prepare attachments if required
if attach is not None and not isinstance(attach, AppriseAttachment):
@ -511,86 +537,45 @@ class Apprise(object):
if interpret_escapes is None else interpret_escapes
# Iterate over our loaded plugins
for server in self.find(tag):
for server in self.find(tag, match_always=match_always):
# If our code reaches here, we either did not define a tag (it
# was set to None), or we did define a tag and the logic above
# determined we need to notify the service it's associated with
if server.notify_format not in conversion_map:
if body_format == NotifyFormat.MARKDOWN and \
server.notify_format == NotifyFormat.HTML:
# Apply Markdown
conversion_map[server.notify_format] = markdown(body)
elif body_format == NotifyFormat.TEXT and \
server.notify_format == NotifyFormat.HTML:
# Basic TEXT to HTML format map; supports keys only
re_map = {
# Support Ampersand
r'&': '&',
# Spaces to   for formatting purposes since
# multiple spaces are treated as one an this may
# not be the callers intention
r' ': ' ',
# Tab support
r'\t': '   ',
# Greater than and Less than Characters
r'>': '>',
r'<': '&lt;',
}
# Compile our map
re_table = re.compile(
r'(' + '|'.join(
map(re.escape, re_map.keys())) + r')',
re.IGNORECASE,
)
# Execute our map against our body in addition to
# swapping out new lines and replacing them with <br/>
conversion_map[server.notify_format] = \
re.sub(r'\r*\n', '<br/>\r\n',
re_table.sub(
lambda x: re_map[x.group()], body))
if server.notify_format not in conversion_body_map:
# Perform Conversion
conversion_body_map[server.notify_format] = \
convert_between(
body_format, server.notify_format, content=body)
# Prepare our title
conversion_title_map[server.notify_format] = \
'' if not title else title
# Tidy Title IF required (hence it will become part of the
# body)
if server.title_maxlen <= 0 and \
conversion_title_map[server.notify_format]:
conversion_title_map[server.notify_format] = \
convert_between(
body_format, server.notify_format,
content=conversion_title_map[server.notify_format])
if interpret_escapes:
#
# Escape our content
#
else:
# Store entry directly
conversion_map[server.notify_format] = body
if interpret_escapes:
#
# Escape our content
#
try:
# Added overhead required due to Python 3 Encoding Bug
# identified here: https://bugs.python.org/issue21331
conversion_map[server.notify_format] = \
conversion_map[server.notify_format]\
.encode('ascii', 'backslashreplace')\
.decode('unicode-escape')
except UnicodeDecodeError: # pragma: no cover
# This occurs using a very old verion of Python 2.7 such
# as the one that ships with CentOS/RedHat 7.x (v2.7.5).
conversion_map[server.notify_format] = \
conversion_map[server.notify_format] \
.decode('string_escape')
except AttributeError:
# Must be of string type
logger.error('Failed to escape message body')
raise TypeError
if title:
try:
# Added overhead required due to Python 3 Encoding Bug
# identified here: https://bugs.python.org/issue21331
title = title\
conversion_body_map[server.notify_format] = \
conversion_body_map[server.notify_format]\
.encode('ascii', 'backslashreplace')\
.decode('unicode-escape')
conversion_title_map[server.notify_format] = \
conversion_title_map[server.notify_format]\
.encode('ascii', 'backslashreplace')\
.decode('unicode-escape')
@ -598,19 +583,46 @@ class Apprise(object):
# This occurs using a very old verion of Python 2.7
# such as the one that ships with CentOS/RedHat 7.x
# (v2.7.5).
title = title.decode('string_escape')
conversion_body_map[server.notify_format] = \
conversion_body_map[server.notify_format] \
.decode('string_escape')
conversion_title_map[server.notify_format] = \
conversion_title_map[server.notify_format] \
.decode('string_escape')
except AttributeError:
# Must be of string type
logger.error('Failed to escape message title')
raise TypeError
msg = 'Failed to escape message body'
logger.error(msg)
raise TypeError(msg)
if six.PY2:
# Python 2.7 strings must be encoded as utf-8 for
# consistency across all platforms
if conversion_body_map[server.notify_format] and \
isinstance(
conversion_body_map[server.notify_format],
unicode): # noqa: F821
conversion_body_map[server.notify_format] = \
conversion_body_map[server.notify_format]\
.encode('utf-8')
if conversion_title_map[server.notify_format] and \
isinstance(
conversion_title_map[server.notify_format],
unicode): # noqa: F821
conversion_title_map[server.notify_format] = \
conversion_title_map[server.notify_format]\
.encode('utf-8')
yield handler(
server,
body=conversion_map[server.notify_format],
title=title,
body=conversion_body_map[server.notify_format],
title=conversion_title_map[server.notify_format],
notify_type=notify_type,
attach=attach
attach=attach,
body_format=body_format,
)
def details(self, lang=None, show_requirements=False, show_disabled=False):

@ -58,6 +58,14 @@ class AppriseAsset(object):
NotifyType.WARNING: '#CACF29',
}
# Ascii Notification
ascii_notify_map = {
NotifyType.INFO: '[i]',
NotifyType.SUCCESS: '[+]',
NotifyType.FAILURE: '[!]',
NotifyType.WARNING: '[~]',
}
# The default color to return if a mapping isn't found in our table above
default_html_color = '#888888'
@ -110,6 +118,9 @@ class AppriseAsset(object):
# to a new line.
interpret_escapes = False
# Defines the encoding of the content passed into Apprise
encoding = 'utf-8'
# For more detail see CWE-312 @
# https://cwe.mitre.org/data/definitions/312.html
#
@ -181,6 +192,15 @@ class AppriseAsset(object):
raise ValueError(
'AppriseAsset html_color(): An invalid color_type was specified.')
def ascii(self, notify_type):
"""
Returns an ascii representation based on passed in notify type
"""
# look our response up
return self.ascii_notify_map.get(notify_type, self.default_html_color)
def image_url(self, notify_type, image_size, logo=False, extension=None):
"""
Apply our mask to our image URL

@ -32,6 +32,7 @@ from . import URLBase
from .AppriseAsset import AppriseAsset
from .common import MATCH_ALL_TAG
from .common import MATCH_ALWAYS_TAG
from .utils import GET_SCHEMA_RE
from .utils import parse_list
from .utils import is_exclusive_match
@ -266,7 +267,7 @@ class AppriseConfig(object):
# Return our status
return True
def servers(self, tag=MATCH_ALL_TAG, *args, **kwargs):
def servers(self, tag=MATCH_ALL_TAG, match_always=True, *args, **kwargs):
"""
Returns all of our servers dynamically build based on parsed
configuration.
@ -277,7 +278,15 @@ class AppriseConfig(object):
This is for filtering the configuration files polled for
results.
If the anytag is set, then any notification that is found
set with that tag are included in the response.
"""
# A match_always flag allows us to pick up on our 'any' keyword
# and notify these services under all circumstances
match_always = MATCH_ALWAYS_TAG if match_always else None
# Build our tag setup
# - top level entries are treated as an 'or'
# - second level (or more) entries are treated as 'and'
@ -294,7 +303,8 @@ class AppriseConfig(object):
# Apply our tag matching based on our defined logic
if is_exclusive_match(
logic=tag, data=entry.tags, match_all=MATCH_ALL_TAG):
logic=tag, data=entry.tags, match_all=MATCH_ALL_TAG,
match_always=match_always):
# Build ourselves a list of services dynamically and return the
# as a list
response.extend(entry.servers())

@ -1,6 +1,6 @@
# -*- coding: utf-8 -*-
#
# Copyright (C) 2021 Chris Caron <lead2gold@gmail.com>
# Copyright (C) 2022 Chris Caron <lead2gold@gmail.com>
# All rights reserved.
#
# This code is licensed under the MIT License.
@ -24,10 +24,10 @@
# THE SOFTWARE.
__title__ = 'Apprise'
__version__ = '0.9.6'
__version__ = '0.9.8.3'
__author__ = 'Chris Caron'
__license__ = 'MIT'
__copywrite__ = 'Copyright (C) 2021 Chris Caron <lead2gold@gmail.com>'
__copywrite__ = 'Copyright (C) 2022 Chris Caron <lead2gold@gmail.com>'
__email__ = 'lead2gold@gmail.com'
__status__ = 'Production'

@ -187,3 +187,7 @@ CONTENT_LOCATIONS = (
# This is a reserved tag that is automatically assigned to every
# Notification Plugin
MATCH_ALL_TAG = 'all'
# Will cause notification to trigger under any circumstance even if an
# exclusive tagging was provided.
MATCH_ALWAYS_TAG = 'always'

@ -0,0 +1,210 @@
# -*- coding: utf-8 -*-
#
# Copyright (C) 2022 Chris Caron <lead2gold@gmail.com>
# All rights reserved.
#
# This code is licensed under the MIT License.
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files(the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and / or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions :
#
# The above copyright notice and this permission notice shall be included in
# all copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
# THE SOFTWARE.
import re
import six
from markdown import markdown
from .common import NotifyFormat
from .URLBase import URLBase
if six.PY2:
from HTMLParser import HTMLParser
else:
from html.parser import HTMLParser
def convert_between(from_format, to_format, content):
"""
Converts between different suported formats. If no conversion exists,
or the selected one fails, the original text will be returned.
This function returns the content translated (if required)
"""
converters = {
(NotifyFormat.MARKDOWN, NotifyFormat.HTML): markdown_to_html,
(NotifyFormat.TEXT, NotifyFormat.HTML): text_to_html,
(NotifyFormat.HTML, NotifyFormat.TEXT): html_to_text,
# For now; use same converter for Markdown support
(NotifyFormat.HTML, NotifyFormat.MARKDOWN): html_to_text,
}
convert = converters.get((from_format, to_format))
return convert(content) if convert else content
def markdown_to_html(content):
"""
Converts specified content from markdown to HTML.
"""
return markdown(content)
def text_to_html(content):
"""
Converts specified content from plain text to HTML.
"""
return URLBase.escape_html(content)
def html_to_text(content):
"""
Converts a content from HTML to plain text.
"""
parser = HTMLConverter()
if six.PY2:
# Python 2.7 requires an additional parsing to un-escape characters
content = parser.unescape(content)
parser.feed(content)
parser.close()
return parser.converted
class HTMLConverter(HTMLParser, object):
"""An HTML to plain text converter tuned for email messages."""
# The following tags must start on a new line
BLOCK_TAGS = ('p', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
'div', 'td', 'th', 'code', 'pre', 'label', 'li',)
# the folowing tags ignore any internal text
IGNORE_TAGS = ('style', 'link', 'meta', 'title', 'html', 'head', 'script')
# Condense Whitespace
WS_TRIM = re.compile(r'[\s]+', re.DOTALL | re.MULTILINE)
# Sentinel value for block tag boundaries, which may be consolidated into a
# single line break.
BLOCK_END = {}
def __init__(self, **kwargs):
super(HTMLConverter, self).__init__(**kwargs)
# Shoudl we store the text content or not?
self._do_store = True
# Initialize internal result list
self._result = []
# Initialize public result field (not populated until close() is
# called)
self.converted = ""
def close(self):
string = ''.join(self._finalize(self._result))
self.converted = string.strip()
if six.PY2:
# See https://stackoverflow.com/questions/10993612/\
# how-to-remove-xa0-from-string-in-python
#
# This is required since the unescape() nbsp; with \xa0 when
# using Python 2.7
self.converted = self.converted.replace(u'\xa0', u' ')
def _finalize(self, result):
"""
Combines and strips consecutive strings, then converts consecutive
block ends into singleton newlines.
[ {be} " Hello " {be} {be} " World!" ] -> "\nHello\nWorld!"
"""
# None means the last visited item was a block end.
accum = None
for item in result:
if item == self.BLOCK_END:
# Multiple consecutive block ends; do nothing.
if accum is None:
continue
# First block end; yield the current string, plus a newline.
yield accum.strip() + '\n'
accum = None
# Multiple consecutive strings; combine them.
elif accum is not None:
accum += item
# First consecutive string; store it.
else:
accum = item
# Yield the last string if we have not already done so.
if accum is not None:
yield accum.strip()
def handle_data(self, data, *args, **kwargs):
"""
Store our data if it is not on the ignore list
"""
# initialize our previous flag
if self._do_store:
# Tidy our whitespace
content = self.WS_TRIM.sub(' ', data)
self._result.append(content)
def handle_starttag(self, tag, attrs):
"""
Process our starting HTML Tag
"""
# Toggle initial states
self._do_store = tag not in self.IGNORE_TAGS
if tag in self.BLOCK_TAGS:
self._result.append(self.BLOCK_END)
if tag == 'li':
self._result.append('- ')
elif tag == 'br':
self._result.append('\n')
elif tag == 'hr':
if self._result:
self._result[-1] = self._result[-1].rstrip(' ')
self._result.append('\n---\n')
elif tag == 'blockquote':
self._result.append(' >')
def handle_endtag(self, tag):
"""
Edge case handling of open/close tags
"""
self._do_store = True
if tag in self.BLOCK_TAGS:
self._result.append(self.BLOCK_END)

@ -1,660 +0,0 @@
# Translations template for apprise.
# Copyright (C) 2021 Chris Caron
# This file is distributed under the same license as the apprise project.
# FIRST AUTHOR <EMAIL@ADDRESS>, 2021.
#
#, fuzzy
msgid ""
msgstr ""
"Project-Id-Version: apprise 0.9.6\n"
"Report-Msgid-Bugs-To: lead2gold@gmail.com\n"
"POT-Creation-Date: 2021-12-01 18:56-0500\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=utf-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Generated-By: Babel 2.9.1\n"
msgid "A local Gnome environment is required."
msgstr ""
msgid "A local Microsoft Windows environment is required."
msgstr ""
msgid "API Key"
msgstr ""
msgid "API Secret"
msgstr ""
msgid "Access Key"
msgstr ""
msgid "Access Key ID"
msgstr ""
msgid "Access Secret"
msgstr ""
msgid "Access Token"
msgstr ""
msgid "Account Email"
msgstr ""
msgid "Account SID"
msgstr ""
msgid "Add Tokens"
msgstr ""
msgid "Alert Type"
msgstr ""
msgid "Alias"
msgstr ""
msgid "Amount"
msgstr ""
msgid "App Access Token"
msgstr ""
msgid "App ID"
msgstr ""
msgid "App Version"
msgstr ""
msgid "Application ID"
msgstr ""
msgid "Application Key"
msgstr ""
msgid "Application Secret"
msgstr ""
msgid "Auth Token"
msgstr ""
msgid "Authentication Key"
msgstr ""
msgid "Authorization Token"
msgstr ""
msgid "Avatar Image"
msgstr ""
msgid "Avatar URL"
msgstr ""
msgid "Batch Mode"
msgstr ""
msgid "Blind Carbon Copy"
msgstr ""
msgid "Bot Name"
msgstr ""
msgid "Bot Token"
msgstr ""
msgid "Cache Age"
msgstr ""
msgid "Cache Results"
msgstr ""
msgid "Call"
msgstr ""
msgid "Carbon Copy"
msgstr ""
msgid "Channels"
msgstr ""
msgid "Client ID"
msgstr ""
msgid "Client Secret"
msgstr ""
msgid "Consumer Key"
msgstr ""
msgid "Consumer Secret"
msgstr ""
msgid "Country"
msgstr ""
msgid "Currency"
msgstr ""
msgid "Custom Icon"
msgstr ""
msgid "Cycles"
msgstr ""
msgid "DBus Notification"
msgstr ""
msgid "Details"
msgstr ""
msgid "Detect Bot Owner"
msgstr ""
msgid "Device"
msgstr ""
msgid "Device API Key"
msgstr ""
msgid "Device ID"
msgstr ""
msgid "Device Name"
msgstr ""
msgid "Display Footer"
msgstr ""
msgid "Domain"
msgstr ""
msgid "Duration"
msgstr ""
msgid "Email"
msgstr ""
msgid "Email Header"
msgstr ""
msgid "Encrypted Password"
msgstr ""
msgid "Encrypted Salt"
msgstr ""
msgid "Entity"
msgstr ""
msgid "Event"
msgstr ""
msgid "Events"
msgstr ""
msgid "Expire"
msgstr ""
msgid "Facility"
msgstr ""
msgid "Flair ID"
msgstr ""
msgid "Flair Text"
msgstr ""
msgid "Footer Logo"
msgstr ""
msgid "Forced File Name"
msgstr ""
msgid "Forced Mime Type"
msgstr ""
msgid "From Email"
msgstr ""
msgid "From Name"
msgstr ""
msgid "From Phone No"
msgstr ""
msgid "Gnome Notification"
msgstr ""
msgid "Group"
msgstr ""
msgid "HTTP Header"
msgstr ""
msgid "Hostname"
msgstr ""
msgid "IRC Colors"
msgstr ""
msgid "Icon Type"
msgstr ""
msgid "Identifier"
msgstr ""
msgid "Image Link"
msgstr ""
msgid "Include Footer"
msgstr ""
msgid "Include Image"
msgstr ""
msgid "Include Segment"
msgstr ""
msgid "Is Ad?"
msgstr ""
msgid "Is Spoiler"
msgstr ""
msgid "Kind"
msgstr ""
msgid "Language"
msgstr ""
msgid "Local File"
msgstr ""
msgid "Log PID"
msgstr ""
msgid "Log to STDERR"
msgstr ""
msgid "Long-Lived Access Token"
msgstr ""
msgid "MacOSX Notification"
msgstr ""
msgid "Master Key"
msgstr ""
msgid "Memory"
msgstr ""
msgid "Message Hook"
msgstr ""
msgid "Message Mode"
msgstr ""
msgid "Message Type"
msgstr ""
msgid "Modal"
msgstr ""
msgid "Mode"
msgstr ""
msgid "NSFW"
msgstr ""
msgid "Name"
msgstr ""
msgid "No dependencies."
msgstr ""
msgid "Notification ID"
msgstr ""
msgid "Notify Format"
msgstr ""
msgid "OAuth Access Token"
msgstr ""
msgid "OAuth2 KeyFile"
msgstr ""
msgid ""
"Only works with Mac OS X 10.8 and higher. Additionally requires that "
"/usr/local/bin/terminal-notifier is locally accessible."
msgstr ""
msgid "Organization"
msgstr ""
msgid "Originating Address"
msgstr ""
msgid "Overflow Mode"
msgstr ""
msgid "Packages are recommended to improve functionality."
msgstr ""
msgid "Packages are required to function."
msgstr ""
msgid "Password"
msgstr ""
msgid "Path"
msgstr ""
msgid "Port"
msgstr ""
msgid "Prefix"
msgstr ""
msgid "Priority"
msgstr ""
msgid "Private Key"
msgstr ""
msgid "Project ID"
msgstr ""
msgid "Provider Key"
msgstr ""
msgid "QOS"
msgstr ""
msgid "Region"
msgstr ""
msgid "Region Name"
msgstr ""
msgid "Remove Tokens"
msgstr ""
msgid "Resubmit Flag"
msgstr ""
msgid "Retry"
msgstr ""
msgid "Rooms"
msgstr ""
msgid "Route"
msgstr ""
msgid "SMTP Server"
msgstr ""
msgid "Schema"
msgstr ""
msgid "Secret Access Key"
msgstr ""
msgid "Secret Key"
msgstr ""
msgid "Secure Mode"
msgstr ""
msgid "Send Replies"
msgstr ""
msgid "Sender ID"
msgstr ""
msgid "Server Key"
msgstr ""
msgid "Server Timeout"
msgstr ""
msgid "Silent Notification"
msgstr ""
msgid "Socket Connect Timeout"
msgstr ""
msgid "Socket Read Timeout"
msgstr ""
msgid "Sound"
msgstr ""
msgid "Sound Link"
msgstr ""
msgid "Source Email"
msgstr ""
msgid "Source JID"
msgstr ""
msgid "Source Phone No"
msgstr ""
msgid "Special Text Color"
msgstr ""
msgid "Sticky"
msgstr ""
msgid "Subtitle"
msgstr ""
msgid "Syslog Mode"
msgstr ""
msgid "Tags"
msgstr ""
msgid "Target Channel"
msgstr ""
msgid "Target Channel ID"
msgstr ""
msgid "Target Chat ID"
msgstr ""
msgid "Target Device"
msgstr ""
msgid "Target Device ID"
msgstr ""
msgid "Target Email"
msgstr ""
msgid "Target Emails"
msgstr ""
msgid "Target Encoded ID"
msgstr ""
msgid "Target Escalation"
msgstr ""
msgid "Target JID"
msgstr ""
msgid "Target Phone No"
msgstr ""
msgid "Target Player ID"
msgstr ""
msgid "Target Queue"
msgstr ""
msgid "Target Room Alias"
msgstr ""
msgid "Target Room ID"
msgstr ""
msgid "Target Schedule"
msgstr ""
msgid "Target Short Code"
msgstr ""
msgid "Target Stream"
msgstr ""
msgid "Target Subreddit"
msgstr ""
msgid "Target Tag ID"
msgstr ""
msgid "Target Team"
msgstr ""
msgid "Target Topic"
msgstr ""
msgid "Target User"
msgstr ""
msgid "Targets"
msgstr ""
msgid "Targets "
msgstr ""
msgid "Team Name"
msgstr ""
msgid "Template"
msgstr ""
msgid "Template Data"
msgstr ""
msgid "Template Path"
msgstr ""
msgid "Template Tokens"
msgstr ""
msgid "Tenant Domain"
msgstr ""
msgid "Text To Speech"
msgstr ""
msgid "To Channel ID"
msgstr ""
msgid "To Email"
msgstr ""
msgid "To User ID"
msgstr ""
msgid "Token"
msgstr ""
msgid "Token A"
msgstr ""
msgid "Token B"
msgstr ""
msgid "Token C"
msgstr ""
msgid "URL"
msgstr ""
msgid "URL Title"
msgstr ""
msgid "Urgency"
msgstr ""
msgid "Use Avatar"
msgstr ""
msgid "Use Blocks"
msgstr ""
msgid "Use Fields"
msgstr ""
msgid "Use Session"
msgstr ""
msgid "User ID"
msgstr ""
msgid "User Key"
msgstr ""
msgid "User Name"
msgstr ""
msgid "Username"
msgstr ""
msgid "Verify SSL"
msgstr ""
msgid "Version"
msgstr ""
msgid "Vibration"
msgstr ""
msgid "Web Based"
msgstr ""
msgid "Web Page Preview"
msgstr ""
msgid "Webhook"
msgstr ""
msgid "Webhook ID"
msgstr ""
msgid "Webhook Key"
msgstr ""
msgid "Webhook Mode"
msgstr ""
msgid "Webhook Token"
msgstr ""
msgid "Workspace"
msgstr ""
msgid "X-Axis"
msgstr ""
msgid "XEP"
msgstr ""
msgid "Y-Axis"
msgstr ""
msgid "libdbus-1.so.x must be installed."
msgstr ""
msgid "ttl"
msgstr ""

@ -1,293 +0,0 @@
# English translations for apprise.
# Copyright (C) 2019 Chris Caron
# This file is distributed under the same license as the apprise project.
# Chris Caron <lead2gold@gmail.com>, 2019.
#
msgid ""
msgstr ""
"Project-Id-Version: apprise 0.7.6\n"
"Report-Msgid-Bugs-To: lead2gold@gmail.com\n"
"POT-Creation-Date: 2019-05-28 16:56-0400\n"
"PO-Revision-Date: 2019-05-24 20:00-0400\n"
"Last-Translator: Chris Caron <lead2gold@gmail.com>\n"
"Language: en\n"
"Language-Team: en <LL@li.org>\n"
"Plural-Forms: nplurals=2; plural=(n != 1)\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=utf-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Generated-By: Babel 2.6.0\n"
msgid "API Key"
msgstr ""
msgid "Access Key"
msgstr ""
msgid "Access Key ID"
msgstr ""
msgid "Access Secret"
msgstr ""
msgid "Access Token"
msgstr ""
msgid "Account SID"
msgstr ""
msgid "Add Tokens"
msgstr ""
msgid "Application Key"
msgstr ""
msgid "Application Secret"
msgstr ""
msgid "Auth Token"
msgstr ""
msgid "Authorization Token"
msgstr ""
msgid "Avatar Image"
msgstr ""
msgid "Bot Name"
msgstr ""
msgid "Bot Token"
msgstr ""
msgid "Channels"
msgstr ""
msgid "Consumer Key"
msgstr ""
msgid "Consumer Secret"
msgstr ""
msgid "Detect Bot Owner"
msgstr ""
msgid "Device ID"
msgstr ""
msgid "Display Footer"
msgstr ""
msgid "Domain"
msgstr ""
msgid "Duration"
msgstr ""
msgid "Events"
msgstr ""
msgid "Footer Logo"
msgstr ""
msgid "From Email"
msgstr ""
msgid "From Name"
msgstr ""
msgid "From Phone No"
msgstr ""
msgid "Group"
msgstr ""
msgid "HTTP Header"
msgstr ""
msgid "Hostname"
msgstr ""
msgid "Include Image"
msgstr ""
msgid "Modal"
msgstr ""
msgid "Notify Format"
msgstr ""
msgid "Organization"
msgstr ""
msgid "Overflow Mode"
msgstr ""
msgid "Password"
msgstr ""
msgid "Port"
msgstr ""
msgid "Priority"
msgstr ""
msgid "Provider Key"
msgstr ""
msgid "Region"
msgstr ""
msgid "Region Name"
msgstr ""
msgid "Remove Tokens"
msgstr ""
msgid "Rooms"
msgstr ""
msgid "SMTP Server"
msgstr ""
msgid "Schema"
msgstr ""
msgid "Secret Access Key"
msgstr ""
msgid "Secret Key"
msgstr ""
msgid "Secure Mode"
msgstr ""
msgid "Server Timeout"
msgstr ""
msgid "Sound"
msgstr ""
msgid "Source JID"
msgstr ""
msgid "Target Channel"
msgstr ""
msgid "Target Chat ID"
msgstr ""
msgid "Target Device"
msgstr ""
msgid "Target Device ID"
msgstr ""
msgid "Target Email"
msgstr ""
msgid "Target Emails"
msgstr ""
msgid "Target Encoded ID"
msgstr ""
msgid "Target JID"
msgstr ""
msgid "Target Phone No"
msgstr ""
msgid "Target Room Alias"
msgstr ""
msgid "Target Room ID"
msgstr ""
msgid "Target Short Code"
msgstr ""
msgid "Target Tag ID"
msgstr ""
msgid "Target Topic"
msgstr ""
msgid "Target User"
msgstr ""
msgid "Targets"
msgstr ""
msgid "Text To Speech"
msgstr ""
msgid "To Channel ID"
msgstr ""
msgid "To Email"
msgstr ""
msgid "To User ID"
msgstr ""
msgid "Token"
msgstr ""
msgid "Token A"
msgstr ""
msgid "Token B"
msgstr ""
msgid "Token C"
msgstr ""
msgid "Urgency"
msgstr ""
msgid "Use Avatar"
msgstr ""
msgid "User"
msgstr ""
msgid "User Key"
msgstr ""
msgid "User Name"
msgstr ""
msgid "Username"
msgstr ""
msgid "Verify SSL"
msgstr ""
msgid "Version"
msgstr ""
msgid "Webhook"
msgstr ""
msgid "Webhook ID"
msgstr ""
msgid "Webhook Mode"
msgstr ""
msgid "Webhook Token"
msgstr ""
msgid "X-Axis"
msgstr ""
msgid "XEP"
msgstr ""
msgid "Y-Axis"
msgstr ""
#~ msgid "Access Key Secret"
#~ msgstr ""

@ -265,7 +265,7 @@ class NotifyBase(BASE_OBJECT):
)
def notify(self, body, title=None, notify_type=NotifyType.INFO,
overflow=None, attach=None, **kwargs):
overflow=None, attach=None, body_format=None, **kwargs):
"""
Performs notification
@ -291,18 +291,22 @@ class NotifyBase(BASE_OBJECT):
title = '' if not title else title
# Apply our overflow (if defined)
for chunk in self._apply_overflow(body=body, title=title,
overflow=overflow):
for chunk in self._apply_overflow(
body=body, title=title, overflow=overflow,
body_format=body_format):
# Send notification
if not self.send(body=chunk['body'], title=chunk['title'],
notify_type=notify_type, attach=attach):
notify_type=notify_type, attach=attach,
body_format=body_format):
# Toggle our return status flag
return False
return True
def _apply_overflow(self, body, title=None, overflow=None):
def _apply_overflow(self, body, title=None, overflow=None,
body_format=None):
"""
Takes the message body and title as input. This function then
applies any defined overflow restrictions associated with the
@ -334,18 +338,24 @@ class NotifyBase(BASE_OBJECT):
overflow = self.overflow_mode
if self.title_maxlen <= 0 and len(title) > 0:
if self.notify_format == NotifyFormat.MARKDOWN:
# Content is appended to body as markdown
body = '**{}**\r\n{}'.format(title, body)
elif self.notify_format == NotifyFormat.HTML:
if self.notify_format == NotifyFormat.HTML:
# Content is appended to body as html
body = '<{open_tag}>{title}</{close_tag}>' \
'<br />\r\n{body}'.format(
open_tag=self.default_html_tag_id,
title=self.escape_html(title),
title=title,
close_tag=self.default_html_tag_id,
body=body)
elif self.notify_format == NotifyFormat.MARKDOWN and \
body_format == NotifyFormat.TEXT:
# Content is appended to body as markdown
title = title.lstrip('\r\n \t\v\f#-')
if title:
# Content is appended to body as text
body = '# {}\r\n{}'.format(title, body)
else:
# Content is appended to body as text
body = '{}\r\n{}'.format(title, body)

@ -0,0 +1,396 @@
# -*- coding: utf-8 -*-
#
# Copyright (C) 2019 Chris Caron <lead2gold@gmail.com>
# All rights reserved.
#
# This code is licensed under the MIT License.
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files(the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and / or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions :
#
# The above copyright notice and this permission notice shall be included in
# all copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
# THE SOFTWARE.
# To use this plugin, sign up with Hampager (you need to be a licensed
# ham radio operator
# http://www.hampager.de/
#
# You're done at this point, you only need to know your user/pass that
# you signed up with.
# The following URLs would be accepted by Apprise:
# - dapnet://{user}:{password}@{callsign}
# - dapnet://{user}:{password}@{callsign1}/{callsign2}
# Optional parameters:
# - priority (NORMAL or EMERGENCY). Default: NORMAL
# - txgroups --> comma-separated list of DAPNET transmitter
# groups. Default: 'dl-all'
# https://hampager.de/#/transmitters/groups
from json import dumps
# The API reference used to build this plugin was documented here:
# https://hampager.de/dokuwiki/doku.php#dapnet_api
#
import requests
from requests.auth import HTTPBasicAuth
from .NotifyBase import NotifyBase
from ..AppriseLocale import gettext_lazy as _
from ..URLBase import PrivacyMode
from ..common import NotifyType
from ..utils import is_call_sign
from ..utils import parse_call_sign
from ..utils import parse_list
from ..utils import parse_bool
class DapnetPriority(object):
NORMAL = 0
EMERGENCY = 1
DAPNET_PRIORITIES = (
DapnetPriority.NORMAL,
DapnetPriority.EMERGENCY,
)
class NotifyDapnet(NotifyBase):
"""
A wrapper for DAPNET / Hampager Notifications
"""
# The default descriptive name associated with the Notification
service_name = 'Dapnet'
# The services URL
service_url = 'https://hampager.de/'
# The default secure protocol
secure_protocol = 'dapnet'
# A URL that takes you to the setup/help of the specific protocol
setup_url = 'https://github.com/caronc/apprise/wiki/Notify_dapnet'
# Dapnet uses the http protocol with JSON requests
notify_url = 'http://www.hampager.de:8080/calls'
# The maximum length of the body
body_maxlen = 80
# A title can not be used for Dapnet Messages. Setting this to zero will
# cause any title (if defined) to get placed into the message body.
title_maxlen = 0
# The maximum amount of emails that can reside within a single transmission
default_batch_size = 50
# Define object templates
templates = ('{schema}://{user}:{password}@{targets}',)
# Define our template tokens
template_tokens = dict(
NotifyBase.template_tokens,
**{
'user': {
'name': _('User Name'),
'type': 'string',
'required': True,
},
'password': {
'name': _('Password'),
'type': 'string',
'private': True,
'required': True,
},
'target_callsign': {
'name': _('Target Callsign'),
'type': 'string',
'regex': (
r'^[a-z0-9]{2,5}(-[a-z0-9]{1,2})?$', 'i',
),
'map_to': 'targets',
},
'targets': {
'name': _('Targets'),
'type': 'list:string',
'required': True,
},
}
)
# Define our template arguments
template_args = dict(
NotifyBase.template_args,
**{
'to': {
'name': _('Target Callsign'),
'type': 'string',
'map_to': 'targets',
},
'priority': {
'name': _('Priority'),
'type': 'choice:int',
'values': DAPNET_PRIORITIES,
'default': DapnetPriority.NORMAL,
},
'txgroups': {
'name': _('Transmitter Groups'),
'type': 'string',
'default': 'dl-all',
'private': True,
},
'batch': {
'name': _('Batch Mode'),
'type': 'bool',
'default': False,
},
}
)
def __init__(self, targets=None, priority=None, txgroups=None,
batch=False, **kwargs):
"""
Initialize Dapnet Object
"""
super(NotifyDapnet, self).__init__(**kwargs)
# Parse our targets
self.targets = list()
# get the emergency prio setting
if priority not in DAPNET_PRIORITIES:
self.priority = self.template_args['priority']['default']
else:
self.priority = priority
if not (self.user and self.password):
msg = 'A Dapnet user/pass was not provided.'
self.logger.warning(msg)
raise TypeError(msg)
# Get the transmitter group
self.txgroups = parse_list(
NotifyDapnet.template_args['txgroups']['default']
if not txgroups else txgroups)
# Prepare Batch Mode Flag
self.batch = batch
for target in parse_call_sign(targets):
# Validate targets and drop bad ones:
result = is_call_sign(target)
if not result:
self.logger.warning(
'Dropping invalid Amateur radio call sign ({}).'.format(
target),
)
continue
# Store callsign without SSID and
# ignore duplicates
if result['callsign'] not in self.targets:
self.targets.append(result['callsign'])
return
def send(self, body, title='', notify_type=NotifyType.INFO, **kwargs):
"""
Perform Dapnet Notification
"""
if not self.targets:
# There is no one to email; we're done
self.logger.warning(
'There are no Amateur radio callsigns to notify')
return False
# Send in batches if identified to do so
batch_size = 1 if not self.batch else self.default_batch_size
headers = {
'User-Agent': self.app_id,
'Content-Type': 'application/json; charset=utf-8',
}
# error tracking (used for function return)
has_error = False
# prepare the emergency mode
emergency_mode = True \
if self.priority == DapnetPriority.EMERGENCY else False
# Create a copy of the targets list
targets = list(self.targets)
for index in range(0, len(targets), batch_size):
# prepare JSON payload
payload = {
'text': body,
'callSignNames': targets[index:index + batch_size],
'transmitterGroupNames': self.txgroups,
'emergency': emergency_mode,
}
self.logger.debug('DAPNET POST URL: %s' % self.notify_url)
self.logger.debug('DAPNET Payload: %s' % dumps(payload))
# Always call throttle before any remote server i/o is made
self.throttle()
try:
r = requests.post(
self.notify_url,
data=dumps(payload),
headers=headers,
auth=HTTPBasicAuth(
username=self.user, password=self.password),
verify=self.verify_certificate,
timeout=self.request_timeout,
)
if r.status_code != requests.codes.created:
# We had a problem
self.logger.warning(
'Failed to send DAPNET notification {} to {}: '
'error={}.'.format(
payload['text'],
' to {}'.format(self.targets),
r.status_code
)
)
self.logger.debug(
'Response Details:\r\n{}'.format(r.content))
# Mark our failure
has_error = True
else:
self.logger.info(
'Sent \'{}\' DAPNET notification {}'.format(
payload['text'], 'to {}'.format(self.targets)
)
)
except requests.RequestException as e:
self.logger.warning(
'A Connection error occurred sending DAPNET '
'notification to {}'.format(self.targets)
)
self.logger.debug('Socket Exception: %s' % str(e))
# Mark our failure
has_error = True
return not has_error
def url(self, privacy=False, *args, **kwargs):
"""
Returns the URL built dynamically based on specified arguments.
"""
# Define any URL parameters
_map = {
DapnetPriority.NORMAL: 'normal',
DapnetPriority.EMERGENCY: 'emergency',
}
# Define any URL parameters
params = {
'priority': 'normal' if self.priority not in _map
else _map[self.priority],
'batch': 'yes' if self.batch else 'no',
'txgroups': ','.join(self.txgroups),
}
# Extend our parameters
params.update(self.url_parameters(privacy=privacy, *args, **kwargs))
# Setup Authentication
auth = '{user}:{password}@'.format(
user=NotifyDapnet.quote(self.user, safe=""),
password=self.pprint(
self.password, privacy, mode=PrivacyMode.Secret, safe=''
),
)
return '{schema}://{auth}{targets}?{params}'.format(
schema=self.secure_protocol,
auth=auth,
targets='/'.join([self.pprint(x, privacy, safe='')
for x in self.targets]),
params=NotifyDapnet.urlencode(params),
)
@staticmethod
def parse_url(url):
"""
Parses the URL and returns enough arguments that can allow
us to re-instantiate this object.
"""
results = NotifyBase.parse_url(url, verify_host=False)
if not results:
# We're done early as we couldn't load the results
return results
# All elements are targets
results['targets'] = [NotifyDapnet.unquote(results['host'])]
# All entries after the hostname are additional targets
results['targets'].extend(NotifyDapnet.split_path(results['fullpath']))
# Support the 'to' variable so that we can support rooms this way too
# The 'to' makes it easier to use yaml configuration
if 'to' in results['qsd'] and len(results['qsd']['to']):
results['targets'] += \
NotifyDapnet.parse_list(results['qsd']['to'])
# Check for priority
if 'priority' in results['qsd'] and len(results['qsd']['priority']):
_map = {
# Letter Assignments
'n': DapnetPriority.NORMAL,
'e': DapnetPriority.EMERGENCY,
'no': DapnetPriority.NORMAL,
'em': DapnetPriority.EMERGENCY,
# Numeric assignments
'0': DapnetPriority.NORMAL,
'1': DapnetPriority.EMERGENCY,
}
try:
results['priority'] = \
_map[results['qsd']['priority'][0:2].lower()]
except KeyError:
# No priority was set
pass
# Check for one or multiple transmitter groups (comma separated)
# and split them up, when necessary
if 'txgroups' in results['qsd']:
results['txgroups'] = \
[x.lower() for x in
NotifyDapnet.parse_list(results['qsd']['txgroups'])]
# Get Batch Mode Flag
results['batch'] = \
parse_bool(results['qsd'].get(
'batch', NotifyDapnet.template_args['batch']['default']))
return results

@ -129,7 +129,7 @@ EMAIL_TEMPLATES = (
r'(?P<domain>(hotmail|live)\.com)$', re.I),
{
'port': 587,
'smtp_host': 'smtp.live.com',
'smtp_host': 'smtp-mail.outlook.com',
'secure': True,
'secure_mode': SecureMailMode.STARTTLS,
'login_type': (WebBaseLogin.EMAIL, )
@ -235,7 +235,6 @@ EMAIL_TEMPLATES = (
},
),
# SendGrid (Email Server)
# You must specify an authenticated sender address in the from= settings
# and a valid email in the to= to deliver your emails to
@ -253,6 +252,36 @@ EMAIL_TEMPLATES = (
},
),
# 163.com
(
'163.com',
re.compile(
r'^((?P<label>[^+]+)\+)?(?P<id>[^@]+)@'
r'(?P<domain>163\.com)$', re.I),
{
'port': 465,
'smtp_host': 'smtp.163.com',
'secure': True,
'secure_mode': SecureMailMode.SSL,
'login_type': (WebBaseLogin.EMAIL, )
},
),
# Foxmail.com
(
'Foxmail.com',
re.compile(
r'^((?P<label>[^+]+)\+)?(?P<id>[^@]+)@'
r'(?P<domain>(foxmail|qq)\.com)$', re.I),
{
'port': 587,
'smtp_host': 'smtp.qq.com',
'secure': True,
'secure_mode': SecureMailMode.STARTTLS,
'login_type': (WebBaseLogin.EMAIL, )
},
),
# Catch All
(
'Custom',
@ -708,8 +737,8 @@ class NotifyEmail(NotifyBase):
attachment.url(privacy=True)))
with open(attachment.path, "rb") as abody:
app = MIMEApplication(
abody.read(), attachment.mimetype)
app = MIMEApplication(abody.read())
app.set_type(attachment.mimetype)
app.add_header(
'Content-Disposition',

@ -52,8 +52,13 @@ from ..NotifyBase import NotifyBase
from ...common import NotifyType
from ...utils import validate_regex
from ...utils import parse_list
from ...utils import parse_bool
from ...common import NotifyImageSize
from ...AppriseAttachment import AppriseAttachment
from ...AppriseLocale import gettext_lazy as _
from .common import (FCMMode, FCM_MODES)
from .priority import (FCM_PRIORITIES, FCMPriorityManager)
from .color import FCMColorManager
# Default our global support flag
NOTIFY_FCM_SUPPORT_ENABLED = False
@ -80,26 +85,6 @@ FCM_HTTP_ERROR_MAP = {
}
class FCMMode(object):
"""
Define the Firebase Cloud Messaging Modes
"""
# The legacy way of sending a message
Legacy = "legacy"
# The new API
OAuth2 = "oauth2"
# FCM Modes
FCM_MODES = (
# Legacy API
FCMMode.Legacy,
# HTTP v1 URL
FCMMode.OAuth2,
)
class NotifyFCM(NotifyBase):
"""
A wrapper for Google's Firebase Cloud Messaging Notifications
@ -136,13 +121,12 @@ class NotifyFCM(NotifyBase):
# If it is more than this, then it is not accepted.
max_fcm_keyfile_size = 5000
# Allows the user to specify the NotifyImageSize object
image_size = NotifyImageSize.XY_256
# The maximum length of the body
body_maxlen = 1024
# A title can not be used for SMS Messages. Setting this to zero will
# cause any title (if defined) to get placed into the message body.
title_maxlen = 0
# Define object templates
templates = (
# OAuth2
@ -163,12 +147,6 @@ class NotifyFCM(NotifyBase):
'type': 'string',
'private': True,
},
'mode': {
'name': _('Mode'),
'type': 'choice:string',
'values': FCM_MODES,
'default': FCMMode.Legacy,
},
'project': {
'name': _('Project ID'),
'type': 'string',
@ -195,10 +173,47 @@ class NotifyFCM(NotifyBase):
'to': {
'alias_of': 'targets',
},
'mode': {
'name': _('Mode'),
'type': 'choice:string',
'values': FCM_MODES,
'default': FCMMode.Legacy,
},
'priority': {
'name': _('Mode'),
'type': 'choice:string',
'values': FCM_PRIORITIES,
},
'image_url': {
'name': _('Custom Image URL'),
'type': 'string',
},
'image': {
'name': _('Include Image'),
'type': 'bool',
'default': False,
'map_to': 'include_image',
},
# Color can either be yes, no, or a #rrggbb (
# rrggbb without hashtag is accepted to)
'color': {
'name': _('Notification Color'),
'type': 'string',
'default': 'yes',
},
})
# Define our data entry
template_kwargs = {
'data_kwargs': {
'name': _('Data Entries'),
'prefix': '+',
},
}
def __init__(self, project, apikey, targets=None, mode=None, keyfile=None,
**kwargs):
data_kwargs=None, image_url=None, include_image=False,
color=None, priority=None, **kwargs):
"""
Initialize Firebase Cloud Messaging
@ -214,7 +229,7 @@ class NotifyFCM(NotifyBase):
self.mode = NotifyFCM.template_tokens['mode']['default'] \
if not isinstance(mode, six.string_types) else mode.lower()
if self.mode and self.mode not in FCM_MODES:
msg = 'The mode specified ({}) is invalid.'.format(mode)
msg = 'The FCM mode specified ({}) is invalid.'.format(mode)
self.logger.warning(msg)
raise TypeError(msg)
@ -267,6 +282,29 @@ class NotifyFCM(NotifyBase):
# Acquire Device IDs to notify
self.targets = parse_list(targets)
# Our data Keyword/Arguments to include in our outbound payload
self.data_kwargs = {}
if isinstance(data_kwargs, dict):
self.data_kwargs.update(data_kwargs)
# Include the image as part of the payload
self.include_image = include_image
# A Custom Image URL
# FCM allows you to provide a remote https?:// URL to an image_url
# located on the internet that it will download and include in the
# payload.
#
# self.image_url() is reserved as an internal function name; so we
# jsut store it into a different variable for now
self.image_src = image_url
# Initialize our priority
self.priority = FCMPriorityManager(self.mode, priority)
# Initialize our color
self.color = FCMColorManager(color, asset=self.asset)
return
@property
@ -335,6 +373,10 @@ class NotifyFCM(NotifyBase):
# Prepare our notify URL
notify_url = self.notify_legacy_url
# Acquire image url
image = self.image_url(notify_type) \
if not self.image_src else self.image_src
has_error = False
# Create a copy of the targets list
targets = list(self.targets)
@ -352,6 +394,17 @@ class NotifyFCM(NotifyBase):
}
}
if self.color:
# Acquire our color
payload['message']['android'] = {
'notification': {'color': self.color.get(notify_type)}}
if self.include_image and image:
payload['message']['notification']['image'] = image
if self.data_kwargs:
payload['message']['data'] = self.data_kwargs
if recipient[0] == '#':
payload['message']['topic'] = recipient[1:]
self.logger.debug(
@ -373,6 +426,18 @@ class NotifyFCM(NotifyBase):
}
}
}
if self.color:
# Acquire our color
payload['notification']['notification']['color'] = \
self.color.get(notify_type)
if self.include_image and image:
payload['notification']['notification']['image'] = image
if self.data_kwargs:
payload['data'] = self.data_kwargs
if recipient[0] == '#':
payload['to'] = '/topics/{}'.format(recipient)
self.logger.debug(
@ -385,6 +450,18 @@ class NotifyFCM(NotifyBase):
"FCM recipient %s parsed as a device token",
recipient)
#
# Apply our priority configuration (if set)
#
def merge(d1, d2):
for k in d2:
if k in d1 and isinstance(d1[k], dict) \
and isinstance(d2[k], dict):
merge(d1[k], d2[k])
else:
d1[k] = d2[k]
merge(payload, self.priority.payload())
self.logger.debug(
'FCM %s POST URL: %s (cert_verify=%r)',
self.mode, notify_url, self.verify_certificate,
@ -443,16 +520,30 @@ class NotifyFCM(NotifyBase):
# Define any URL parameters
params = {
'mode': self.mode,
'image': 'yes' if self.include_image else 'no',
'color': str(self.color),
}
if self.priority:
# Store our priority if one was defined
params['priority'] = str(self.priority)
if self.keyfile:
# Include our keyfile if specified
params['keyfile'] = NotifyFCM.quote(
self.keyfile[0].url(privacy=privacy), safe='')
if self.image_src:
# Include our image path as part of our URL payload
params['image_url'] = self.image_src
# Extend our parameters
params.update(self.url_parameters(privacy=privacy, *args, **kwargs))
# Add our data keyword/args into our URL response
params.update(
{'+{}'.format(k): v for k, v in self.data_kwargs.items()})
reference = NotifyFCM.quote(self.project) \
if self.mode == FCMMode.OAuth2 \
else self.pprint(self.apikey, privacy, safe='')
@ -507,4 +598,30 @@ class NotifyFCM(NotifyBase):
results['keyfile'] = \
NotifyFCM.unquote(results['qsd']['keyfile'])
# Our Priority
if 'priority' in results['qsd'] and results['qsd']['priority']:
results['priority'] = \
NotifyFCM.unquote(results['qsd']['priority'])
# Our Color
if 'color' in results['qsd'] and results['qsd']['color']:
results['color'] = \
NotifyFCM.unquote(results['qsd']['color'])
# Boolean to include an image or not
results['include_image'] = parse_bool(results['qsd'].get(
'image', NotifyFCM.template_args['image']['default']))
# Extract image_url if it was specified
if 'image_url' in results['qsd']:
results['image_url'] = \
NotifyFCM.unquote(results['qsd']['image_url'])
if 'image' not in results['qsd']:
# Toggle default behaviour if a custom image was provided
# but ONLY if the `image` boolean was not set
results['include_image'] = True
# Store our data keyword/args if specified
results['data_kwargs'] = results['qsd+']
return results

@ -0,0 +1,127 @@
# -*- coding: utf-8 -*-
#
# Copyright (C) 2021 Chris Caron <lead2gold@gmail.com>
# All rights reserved.
#
# This code is licensed under the MIT License.
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files(the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and / or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions :
#
# The above copyright notice and this permission notice shall be included in
# all copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
# THE SOFTWARE.
# New priorities are defined here:
# - https://firebase.google.com/docs/reference/fcm/rest/v1/\
# projects.messages#NotificationPriority
# Legacy color payload example here:
# https://firebase.google.com/docs/reference/fcm/rest/v1/\
# projects.messages#androidnotification
import re
import six
from ...utils import parse_bool
from ...common import NotifyType
from ...AppriseAsset import AppriseAsset
class FCMColorManager(object):
"""
A Simple object to accept either a boolean value
- True: Use colors provided by Apprise
- False: Do not use colors at all
- rrggbb: where you provide the rgb values (hence #333333)
- rgb: is also accepted as rgb values (hence #333)
For RGB colors, the hashtag is optional
"""
__color_rgb = re.compile(
r'#?((?P<r1>[0-9A-F]{2})(?P<g1>[0-9A-F]{2})(?P<b1>[0-9A-F]{2})'
r'|(?P<r2>[0-9A-F])(?P<g2>[0-9A-F])(?P<b2>[0-9A-F]))', re.IGNORECASE)
def __init__(self, color, asset=None):
"""
Parses the color object accordingly
"""
# Initialize an asset object if one isn't otherwise defined
self.asset = asset \
if isinstance(asset, AppriseAsset) else AppriseAsset()
# Prepare our color
self.color = color
if isinstance(color, six.string_types):
self.color = self.__color_rgb.match(color)
if self.color:
# Store our RGB value as #rrggbb
self.color = '{red}{green}{blue}'.format(
red=self.color.group('r1'),
green=self.color.group('g1'),
blue=self.color.group('b1')).lower() \
if self.color.group('r1') else \
'{red1}{red2}{green1}{green2}{blue1}{blue2}'.format(
red1=self.color.group('r2'),
red2=self.color.group('r2'),
green1=self.color.group('g2'),
green2=self.color.group('g2'),
blue1=self.color.group('b2'),
blue2=self.color.group('b2')).lower()
if self.color is None:
# Color not determined, so base it on boolean parser
self.color = parse_bool(color)
def get(self, notify_type=NotifyType.INFO):
"""
Returns color or true/false value based on configuration
"""
if isinstance(self.color, bool) and self.color:
# We want to use the asset value
return self.asset.color(notify_type=notify_type)
elif self.color:
# return our color as is
return '#' + self.color
# No color to return
return None
def __str__(self):
"""
our color representation
"""
if isinstance(self.color, bool):
return 'yes' if self.color else 'no'
# otherwise return our color
return self.color
def __bool__(self):
"""
Allows this object to be wrapped in an Python 3.x based 'if
statement'. True is returned if a color was loaded
"""
return True if self.color is True or \
isinstance(self.color, six.string_types) else False
def __nonzero__(self):
"""
Allows this object to be wrapped in an Python 2.x based 'if
statement'. True is returned if a color was loaded
"""
return True if self.color is True or \
isinstance(self.color, six.string_types) else False

@ -0,0 +1,42 @@
# -*- coding: utf-8 -*-
#
# Copyright (C) 2021 Chris Caron <lead2gold@gmail.com>
# All rights reserved.
#
# This code is licensed under the MIT License.
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files(the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and / or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions :
#
# The above copyright notice and this permission notice shall be included in
# all copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
# THE SOFTWARE.
class FCMMode(object):
"""
Define the Firebase Cloud Messaging Modes
"""
# The legacy way of sending a message
Legacy = "legacy"
# The new API
OAuth2 = "oauth2"
# FCM Modes
FCM_MODES = (
# Legacy API
FCMMode.Legacy,
# HTTP v1 URL
FCMMode.OAuth2,
)

@ -0,0 +1,255 @@
# -*- coding: utf-8 -*-
#
# Copyright (C) 2021 Chris Caron <lead2gold@gmail.com>
# All rights reserved.
#
# This code is licensed under the MIT License.
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files(the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and / or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions :
#
# The above copyright notice and this permission notice shall be included in
# all copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
# THE SOFTWARE.
# New priorities are defined here:
# - https://firebase.google.com/docs/reference/fcm/rest/v1/\
# projects.messages#NotificationPriority
# Legacy priorities are defined here:
# - https://firebase.google.com/docs/cloud-messaging/http-server-ref
from .common import (FCMMode, FCM_MODES)
from ...logger import logger
class NotificationPriority(object):
"""
Defines the Notification Priorities as described on:
https://firebase.google.com/docs/reference/fcm/rest/v1/\
projects.messages#androidmessagepriority
NORMAL:
Default priority for data messages. Normal priority messages won't
open network connections on a sleeping device, and their delivery
may be delayed to conserve the battery. For less time-sensitive
messages, such as notifications of new email or other data to sync,
choose normal delivery priority.
HIGH:
Default priority for notification messages. FCM attempts to
deliver high priority messages immediately, allowing the FCM
service to wake a sleeping device when possible and open a network
connection to your app server. Apps with instant messaging, chat,
or voice call alerts, for example, generally need to open a
network connection and make sure FCM delivers the message to the
device without delay. Set high priority if the message is
time-critical and requires the user's immediate interaction, but
beware that setting your messages to high priority contributes
more to battery drain compared with normal priority messages.
"""
NORMAL = 'NORMAL'
HIGH = 'HIGH'
class FCMPriority(object):
"""
Defines our accepted priorites
"""
MIN = "min"
LOW = "low"
NORMAL = "normal"
HIGH = "high"
MAX = "max"
FCM_PRIORITIES = (
FCMPriority.MIN,
FCMPriority.LOW,
FCMPriority.NORMAL,
FCMPriority.HIGH,
FCMPriority.MAX,
)
class FCMPriorityManager(object):
"""
A Simple object to make it easier to work with FCM set priorities
"""
priority_map = {
FCMPriority.MIN: {
FCMMode.OAuth2: {
'message': {
'android': {
'priority': NotificationPriority.NORMAL
},
'apns': {
'headers': {
'apns-priority': "5"
}
},
'webpush': {
'headers': {
'Urgency': 'very-low'
}
},
}
},
FCMMode.Legacy: {
'priority': 'normal',
}
},
FCMPriority.LOW: {
FCMMode.OAuth2: {
'message': {
'android': {
'priority': NotificationPriority.NORMAL
},
'apns': {
'headers': {
'apns-priority': "5"
}
},
'webpush': {
'headers': {
'Urgency': 'low'
}
}
}
},
FCMMode.Legacy: {
'priority': 'normal',
}
},
FCMPriority.NORMAL: {
FCMMode.OAuth2: {
'message': {
'android': {
'priority': NotificationPriority.NORMAL
},
'apns': {
'headers': {
'apns-priority': "5"
}
},
'webpush': {
'headers': {
'Urgency': 'normal'
}
}
}
},
FCMMode.Legacy: {
'priority': 'normal',
}
},
FCMPriority.HIGH: {
FCMMode.OAuth2: {
'message': {
'android': {
'priority': NotificationPriority.HIGH
},
'apns': {
'headers': {
'apns-priority': "10"
}
},
'webpush': {
'headers': {
'Urgency': 'high'
}
}
}
},
FCMMode.Legacy: {
'priority': 'high',
}
},
FCMPriority.MAX: {
FCMMode.OAuth2: {
'message': {
'android': {
'priority': NotificationPriority.HIGH
},
'apns': {
'headers': {
'apns-priority': "10"
}
},
'webpush': {
'headers': {
'Urgency': 'high'
}
}
}
},
FCMMode.Legacy: {
'priority': 'high',
}
}
}
def __init__(self, mode, priority=None):
"""
Takes a FCMMode and Priority
"""
self.mode = mode
if self.mode not in FCM_MODES:
msg = 'The FCM mode specified ({}) is invalid.'.format(mode)
logger.warning(msg)
raise TypeError(msg)
self.priority = None
if priority:
self.priority = \
next((p for p in FCM_PRIORITIES
if p.startswith(priority[:2].lower())), None)
if not self.priority:
msg = 'An invalid FCM Priority ' \
'({}) was specified.'.format(priority)
logger.warning(msg)
raise TypeError(msg)
def payload(self):
"""
Returns our payload depending on our mode
"""
return self.priority_map[self.priority][self.mode] \
if self.priority else {}
def __str__(self):
"""
our priority representation
"""
return self.priority if self.priority else ''
def __bool__(self):
"""
Allows this object to be wrapped in an Python 3.x based 'if
statement'. True is returned if a priority was loaded
"""
return True if self.priority else False
def __nonzero__(self):
"""
Allows this object to be wrapped in an Python 2.x based 'if
statement'. True is returned if a priority was loaded
"""
return True if self.priority else False

@ -0,0 +1,393 @@
# -*- coding: utf-8 -*-
#
# Copyright (C) 2022 Chris Caron <lead2gold@gmail.com>
# All rights reserved.
#
# This code is licensed under the MIT License.
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files(the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and / or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions :
#
# The above copyright notice and this permission notice shall be included in
# all copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
# THE SOFTWARE.
import six
import requests
from .NotifyBase import NotifyBase
from ..URLBase import PrivacyMode
from ..common import NotifyImageSize
from ..common import NotifyType
from ..AppriseLocale import gettext_lazy as _
# Defines the method to send the notification
METHODS = (
'POST',
'GET',
'DELETE',
'PUT',
'HEAD'
)
class NotifyForm(NotifyBase):
"""
A wrapper for Form Notifications
"""
# The default descriptive name associated with the Notification
service_name = 'Form'
# The default protocol
protocol = 'form'
# The default secure protocol
secure_protocol = 'forms'
# A URL that takes you to the setup/help of the specific protocol
setup_url = 'https://github.com/caronc/apprise/wiki/Notify_Custom_Form'
# Allows the user to specify the NotifyImageSize object
image_size = NotifyImageSize.XY_128
# Disable throttle rate for Form requests since they are normally
# local anyway
request_rate_per_sec = 0
# Define object templates
templates = (
'{schema}://{host}',
'{schema}://{host}:{port}',
'{schema}://{user}@{host}',
'{schema}://{user}@{host}:{port}',
'{schema}://{user}:{password}@{host}',
'{schema}://{user}:{password}@{host}:{port}',
)
# Define our tokens; these are the minimum tokens required required to
# be passed into this function (as arguments). The syntax appends any
# previously defined in the base package and builds onto them
template_tokens = dict(NotifyBase.template_tokens, **{
'host': {
'name': _('Hostname'),
'type': 'string',
'required': True,
},
'port': {
'name': _('Port'),
'type': 'int',
'min': 1,
'max': 65535,
},
'user': {
'name': _('Username'),
'type': 'string',
},
'password': {
'name': _('Password'),
'type': 'string',
'private': True,
},
})
# Define our template arguments
template_args = dict(NotifyBase.template_args, **{
'method': {
'name': _('Fetch Method'),
'type': 'choice:string',
'values': METHODS,
'default': METHODS[0],
},
})
# Define any kwargs we're using
template_kwargs = {
'headers': {
'name': _('HTTP Header'),
'prefix': '+',
},
'payload': {
'name': _('Payload Extras'),
'prefix': ':',
},
}
def __init__(self, headers=None, method=None, payload=None, **kwargs):
"""
Initialize Form Object
headers can be a dictionary of key/value pairs that you want to
additionally include as part of the server headers to post with
"""
super(NotifyForm, self).__init__(**kwargs)
self.fullpath = kwargs.get('fullpath')
if not isinstance(self.fullpath, six.string_types):
self.fullpath = ''
self.method = self.template_args['method']['default'] \
if not isinstance(method, six.string_types) else method.upper()
if self.method not in METHODS:
msg = 'The method specified ({}) is invalid.'.format(method)
self.logger.warning(msg)
raise TypeError(msg)
self.headers = {}
if headers:
# Store our extra headers
self.headers.update(headers)
self.payload_extras = {}
if payload:
# Store our extra payload entries
self.payload_extras.update(payload)
return
def url(self, privacy=False, *args, **kwargs):
"""
Returns the URL built dynamically based on specified arguments.
"""
# Define any URL parameters
params = {
'method': self.method,
}
# Extend our parameters
params.update(self.url_parameters(privacy=privacy, *args, **kwargs))
# Append our headers into our parameters
params.update({'+{}'.format(k): v for k, v in self.headers.items()})
# Append our payload extra's into our parameters
params.update(
{':{}'.format(k): v for k, v in self.payload_extras.items()})
# Determine Authentication
auth = ''
if self.user and self.password:
auth = '{user}:{password}@'.format(
user=NotifyForm.quote(self.user, safe=''),
password=self.pprint(
self.password, privacy, mode=PrivacyMode.Secret, safe=''),
)
elif self.user:
auth = '{user}@'.format(
user=NotifyForm.quote(self.user, safe=''),
)
default_port = 443 if self.secure else 80
return '{schema}://{auth}{hostname}{port}{fullpath}?{params}'.format(
schema=self.secure_protocol if self.secure else self.protocol,
auth=auth,
# never encode hostname since we're expecting it to be a valid one
hostname=self.host,
port='' if self.port is None or self.port == default_port
else ':{}'.format(self.port),
fullpath=NotifyForm.quote(self.fullpath, safe='/')
if self.fullpath else '/',
params=NotifyForm.urlencode(params),
)
def send(self, body, title='', notify_type=NotifyType.INFO, attach=None,
**kwargs):
"""
Perform Form Notification
"""
headers = {
'User-Agent': self.app_id,
}
# Apply any/all header over-rides defined
headers.update(self.headers)
# Track our potential attachments
files = []
if attach:
for no, attachment in enumerate(attach, start=1):
# Perform some simple error checking
if not attachment:
# We could not access the attachment
self.logger.error(
'Could not access attachment {}.'.format(
attachment.url(privacy=True)))
return False
try:
files.append((
'file{:02d}'.format(no), (
attachment.name,
open(attachment.path, 'rb'),
attachment.mimetype)
))
except (OSError, IOError) as e:
self.logger.warning(
'An I/O error occurred while opening {}.'.format(
attachment.name if attachment else 'attachment'))
self.logger.debug('I/O Exception: %s' % str(e))
return False
finally:
for file in files:
# Ensure all files are closed
if file[1][1]:
file[1][1].close()
# prepare Form Object
payload = {
# Version: Major.Minor, Major is only updated if the entire
# schema is changed. If just adding new items (or removing
# old ones, only increment the Minor!
'version': '1.0',
'title': title,
'message': body,
'type': notify_type,
}
# Apply any/all payload over-rides defined
payload.update(self.payload_extras)
auth = None
if self.user:
auth = (self.user, self.password)
# Set our schema
schema = 'https' if self.secure else 'http'
url = '%s://%s' % (schema, self.host)
if isinstance(self.port, int):
url += ':%d' % self.port
url += self.fullpath
self.logger.debug('Form %s URL: %s (cert_verify=%r)' % (
self.method, url, self.verify_certificate,
))
self.logger.debug('Form Payload: %s' % str(payload))
# Always call throttle before any remote server i/o is made
self.throttle()
if self.method == 'GET':
method = requests.get
elif self.method == 'PUT':
method = requests.put
elif self.method == 'DELETE':
method = requests.delete
elif self.method == 'HEAD':
method = requests.head
else: # POST
method = requests.post
try:
r = method(
url,
files=None if not files else files,
data=payload if self.method != 'GET' else None,
params=payload if self.method == 'GET' else None,
headers=headers,
auth=auth,
verify=self.verify_certificate,
timeout=self.request_timeout,
)
if r.status_code < 200 or r.status_code >= 300:
# We had a problem
status_str = \
NotifyForm.http_response_code_lookup(r.status_code)
self.logger.warning(
'Failed to send Form %s notification: %s%serror=%s.',
self.method,
status_str,
', ' if status_str else '',
str(r.status_code))
self.logger.debug('Response Details:\r\n{}'.format(r.content))
# Return; we're done
return False
else:
self.logger.info('Sent Form %s notification.', self.method)
except requests.RequestException as e:
self.logger.warning(
'A Connection error occurred sending Form '
'notification to %s.' % self.host)
self.logger.debug('Socket Exception: %s' % str(e))
# Return; we're done
return False
except (OSError, IOError) as e:
self.logger.warning(
'An I/O error occurred while reading one of the '
'attached files.')
self.logger.debug('I/O Exception: %s' % str(e))
return False
finally:
for file in files:
# Ensure all files are closed
file[1][1].close()
return True
@staticmethod
def parse_url(url):
"""
Parses the URL and returns enough arguments that can allow
us to re-instantiate this object.
"""
results = NotifyBase.parse_url(url)
if not results:
# We're done early as we couldn't load the results
return results
# store any additional payload extra's defined
results['payload'] = {NotifyForm.unquote(x): NotifyForm.unquote(y)
for x, y in results['qsd:'].items()}
# Add our headers that the user can potentially over-ride if they wish
# to to our returned result set
results['headers'] = results['qsd+']
if results['qsd-']:
results['headers'].update(results['qsd-'])
NotifyBase.logger.deprecate(
"minus (-) based Form header tokens are being "
" removed; use the plus (+) symbol instead.")
# Tidy our header entries by unquoting them
results['headers'] = {NotifyForm.unquote(x): NotifyForm.unquote(y)
for x, y in results['headers'].items()}
# Set method if not otherwise set
if 'method' in results['qsd'] and len(results['qsd']['method']):
results['method'] = NotifyForm.unquote(results['qsd']['method'])
return results

@ -170,11 +170,6 @@ class NotifyGotify(NotifyBase):
# Append our remaining path
url += '{fullpath}message'.format(fullpath=self.fullpath)
# Define our parameteers
params = {
'token': self.token,
}
# Prepare Gotify Object
payload = {
'priority': self.priority,
@ -193,6 +188,7 @@ class NotifyGotify(NotifyBase):
headers = {
'User-Agent': self.app_id,
'Content-Type': 'application/json',
'X-Gotify-Key': self.token,
}
self.logger.debug('Gotify POST URL: %s (cert_verify=%r)' % (
@ -206,7 +202,6 @@ class NotifyGotify(NotifyBase):
try:
r = requests.post(
url,
params=params,
data=dumps(payload),
headers=headers,
verify=self.verify_certificate,

@ -35,6 +35,16 @@ from ..common import NotifyType
from ..AppriseLocale import gettext_lazy as _
# Defines the method to send the notification
METHODS = (
'POST',
'GET',
'DELETE',
'PUT',
'HEAD'
)
class NotifyJSON(NotifyBase):
"""
A wrapper for JSON Notifications
@ -93,6 +103,17 @@ class NotifyJSON(NotifyBase):
'type': 'string',
'private': True,
},
})
# Define our template arguments
template_args = dict(NotifyBase.template_args, **{
'method': {
'name': _('Fetch Method'),
'type': 'choice:string',
'values': METHODS,
'default': METHODS[0],
},
})
# Define any kwargs we're using
@ -101,9 +122,13 @@ class NotifyJSON(NotifyBase):
'name': _('HTTP Header'),
'prefix': '+',
},
'payload': {
'name': _('Payload Extras'),
'prefix': ':',
},
}
def __init__(self, headers=None, **kwargs):
def __init__(self, headers=None, method=None, payload=None, **kwargs):
"""
Initialize JSON Object
@ -115,13 +140,26 @@ class NotifyJSON(NotifyBase):
self.fullpath = kwargs.get('fullpath')
if not isinstance(self.fullpath, six.string_types):
self.fullpath = '/'
self.fullpath = ''
self.method = self.template_args['method']['default'] \
if not isinstance(method, six.string_types) else method.upper()
if self.method not in METHODS:
msg = 'The method specified ({}) is invalid.'.format(method)
self.logger.warning(msg)
raise TypeError(msg)
self.headers = {}
if headers:
# Store our extra headers
self.headers.update(headers)
self.payload_extras = {}
if payload:
# Store our extra payload entries
self.payload_extras.update(payload)
return
def url(self, privacy=False, *args, **kwargs):
@ -129,12 +167,21 @@ class NotifyJSON(NotifyBase):
Returns the URL built dynamically based on specified arguments.
"""
# Our URL parameters
params = self.url_parameters(privacy=privacy, *args, **kwargs)
# Define any URL parameters
params = {
'method': self.method,
}
# Extend our parameters
params.update(self.url_parameters(privacy=privacy, *args, **kwargs))
# Append our headers into our parameters
params.update({'+{}'.format(k): v for k, v in self.headers.items()})
# Append our payload extra's into our parameters
params.update(
{':{}'.format(k): v for k, v in self.payload_extras.items()})
# Determine Authentication
auth = ''
if self.user and self.password:
@ -150,14 +197,15 @@ class NotifyJSON(NotifyBase):
default_port = 443 if self.secure else 80
return '{schema}://{auth}{hostname}{port}{fullpath}/?{params}'.format(
return '{schema}://{auth}{hostname}{port}{fullpath}?{params}'.format(
schema=self.secure_protocol if self.secure else self.protocol,
auth=auth,
# never encode hostname since we're expecting it to be a valid one
hostname=self.host,
port='' if self.port is None or self.port == default_port
else ':{}'.format(self.port),
fullpath=NotifyJSON.quote(self.fullpath, safe='/'),
fullpath=NotifyJSON.quote(self.fullpath, safe='/')
if self.fullpath else '/',
params=NotifyJSON.urlencode(params),
)
@ -217,6 +265,9 @@ class NotifyJSON(NotifyBase):
'type': notify_type,
}
# Apply any/all payload over-rides defined
payload.update(self.payload_extras)
auth = None
if self.user:
auth = (self.user, self.password)
@ -238,8 +289,23 @@ class NotifyJSON(NotifyBase):
# Always call throttle before any remote server i/o is made
self.throttle()
if self.method == 'GET':
method = requests.get
elif self.method == 'PUT':
method = requests.put
elif self.method == 'DELETE':
method = requests.delete
elif self.method == 'HEAD':
method = requests.head
else: # POST
method = requests.post
try:
r = requests.post(
r = method(
url,
data=dumps(payload),
headers=headers,
@ -247,17 +313,17 @@ class NotifyJSON(NotifyBase):
verify=self.verify_certificate,
timeout=self.request_timeout,
)
if r.status_code != requests.codes.ok:
if r.status_code < 200 or r.status_code >= 300:
# We had a problem
status_str = \
NotifyJSON.http_response_code_lookup(r.status_code)
self.logger.warning(
'Failed to send JSON notification: '
'{}{}error={}.'.format(
status_str,
', ' if status_str else '',
r.status_code))
'Failed to send JSON %s notification: %s%serror=%s.',
self.method,
status_str,
', ' if status_str else '',
str(r.status_code))
self.logger.debug('Response Details:\r\n{}'.format(r.content))
@ -265,7 +331,7 @@ class NotifyJSON(NotifyBase):
return False
else:
self.logger.info('Sent JSON notification.')
self.logger.info('Sent JSON %s notification.', self.method)
except requests.RequestException as e:
self.logger.warning(
@ -290,6 +356,10 @@ class NotifyJSON(NotifyBase):
# We're done early as we couldn't load the results
return results
# store any additional payload extra's defined
results['payload'] = {NotifyJSON.unquote(x): NotifyJSON.unquote(y)
for x, y in results['qsd:'].items()}
# Add our headers that the user can potentially over-ride if they wish
# to to our returned result set
results['headers'] = results['qsd+']
@ -303,4 +373,8 @@ class NotifyJSON(NotifyBase):
results['headers'] = {NotifyJSON.unquote(x): NotifyJSON.unquote(y)
for x, y in results['headers'].items()}
# Set method if not otherwise set
if 'method' in results['qsd'] and len(results['qsd']['method']):
results['method'] = NotifyJSON.unquote(results['qsd']['method'])
return results

@ -91,8 +91,11 @@ class NotifyMacOSX(NotifyBase):
# content to display
body_max_line_count = 10
# The path to the terminal-notifier
notify_path = '/usr/local/bin/terminal-notifier'
# The possible paths to the terminal-notifier
notify_paths = (
'/opt/homebrew/bin/terminal-notifier',
'/usr/local/bin/terminal-notifier',
)
# Define object templates
templates = (
@ -127,6 +130,10 @@ class NotifyMacOSX(NotifyBase):
# or not.
self.include_image = include_image
# Acquire the notify path
self.notify_path = next( # pragma: no branch
(p for p in self.notify_paths if os.access(p, os.X_OK)), None)
# Set sound object (no q/a for now)
self.sound = sound
return
@ -136,10 +143,11 @@ class NotifyMacOSX(NotifyBase):
Perform MacOSX Notification
"""
if not os.access(self.notify_path, os.X_OK):
if not (self.notify_path and os.access(self.notify_path, os.X_OK)):
self.logger.warning(
"MacOSX Notifications require '{}' to be in place."
.format(self.notify_path))
"MacOSX Notifications requires one of the following to "
"be in place: '{}'.".format(
'\', \''.join(self.notify_paths)))
return False
# Start with our notification path

@ -42,7 +42,7 @@ from ..common import NotifyImageSize
from ..common import NotifyFormat
from ..utils import parse_bool
from ..utils import parse_list
from ..utils import apply_template
from ..utils import is_hostname
from ..utils import validate_regex
from ..AppriseLocale import gettext_lazy as _
@ -287,6 +287,22 @@ class NotifyMatrix(NotifyBase):
self.logger.warning(msg)
raise TypeError(msg)
elif not is_hostname(self.host):
msg = 'An invalid Matrix Hostname ({}) was specified'\
.format(self.host)
self.logger.warning(msg)
raise TypeError(msg)
else:
# Verify port if specified
if self.port is not None and not (
isinstance(self.port, int)
and self.port >= self.template_tokens['port']['min']
and self.port <= self.template_tokens['port']['max']):
msg = 'An invalid Matrix Port ({}) was specified'\
.format(self.port)
self.logger.warning(msg)
raise TypeError(msg)
def send(self, body, title='', notify_type=NotifyType.INFO, **kwargs):
"""
Perform Matrix Notification
@ -453,21 +469,16 @@ class NotifyMatrix(NotifyBase):
}
if self.notify_format == NotifyFormat.HTML:
# Add additional information to our content; use {{app_title}}
# to apply the title to the html body
tokens = {
'app_title': NotifyMatrix.escape_html(
title, whitespace=False),
}
payload['text'] = apply_template(body, **tokens)
payload['text'] = '{title}{body}'.format(
title='' if not title else '<h1>{}</h1>'.format(
NotifyMatrix.escape_html(title)),
body=body)
elif self.notify_format == NotifyFormat.MARKDOWN:
# Add additional information to our content; use {{app_title}}
# to apply the title to the html body
tokens = {
'app_title': title,
}
payload['text'] = markdown(apply_template(body, **tokens))
payload['text'] = '{title}{body}'.format(
title='' if not title else '<h1>{}</h1>'.format(
NotifyMatrix.escape_html(title)),
body=markdown(body))
else: # NotifyFormat.TEXT
payload['text'] = \
@ -566,32 +577,29 @@ class NotifyMatrix(NotifyBase):
payload = {
'msgtype': 'm.{}'.format(self.msgtype),
'body': '{title}{body}'.format(
title='' if not title else '{}\r\n'.format(title),
title='' if not title else '# {}\r\n'.format(title),
body=body),
}
# Update our payload advance formatting for the services that
# support them.
if self.notify_format == NotifyFormat.HTML:
# Add additional information to our content; use {{app_title}}
# to apply the title to the html body
tokens = {
'app_title': NotifyMatrix.escape_html(
title, whitespace=False),
}
payload.update({
'format': 'org.matrix.custom.html',
'formatted_body': apply_template(body, **tokens),
'formatted_body': '{title}{body}'.format(
title='' if not title else '<h1>{}</h1>'.format(title),
body=body,
)
})
elif self.notify_format == NotifyFormat.MARKDOWN:
tokens = {
'app_title': title,
}
payload.update({
'format': 'org.matrix.custom.html',
'formatted_body': markdown(apply_template(body, **tokens))
'formatted_body': '{title}{body}'.format(
title='' if not title else '<h1>{}</h1>'.format(
NotifyMatrix.escape_html(title, whitespace=False)),
body=markdown(body),
)
})
# Build our path

@ -0,0 +1,281 @@
# -*- coding: utf-8 -*-
#
# Copyright (C) 2019 Chris Caron <lead2gold@gmail.com>
# All rights reserved.
#
# This code is licensed under the MIT License.
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files(the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and / or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions :
#
# The above copyright notice and this permission notice shall be included in
# all copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CON
import requests
from .NotifyBase import NotifyBase
from ..URLBase import PrivacyMode
from ..common import NotifyType
from ..utils import parse_list
from ..AppriseLocale import gettext_lazy as _
class NotifyNextcloudTalk(NotifyBase):
"""
A wrapper for Nextcloud Talk Notifications
"""
# The default descriptive name associated with the Notification
service_name = _('Nextcloud Talk')
# The services URL
service_url = 'https://nextcloud.com/talk'
# Insecure protocol (for those self hosted requests)
protocol = 'nctalk'
# The default protocol (this is secure for notica)
secure_protocol = 'nctalks'
# A URL that takes you to the setup/help of the specific protocol
setup_url = 'https://github.com/caronc/apprise/wiki/Notify_nextcloudtalk'
# Nextcloud title length
title_maxlen = 255
# Defines the maximum allowable characters per message.
body_maxlen = 4000
# Define object templates
templates = (
'{schema}://{user}:{password}@{host}/{targets}',
'{schema}://{user}:{password}@{host}:{port}/{targets}',
)
# Define our template tokens
template_tokens = dict(NotifyBase.template_tokens, **{
'host': {
'name': _('Hostname'),
'type': 'string',
'required': True,
},
'port': {
'name': _('Port'),
'type': 'int',
'min': 1,
'max': 65535,
},
'user': {
'name': _('Username'),
'type': 'string',
'required': True,
},
'password': {
'name': _('Password'),
'type': 'string',
'private': True,
'required': True,
},
'targets': {
'name': _('Targets'),
'type': 'list:string',
'required': True,
},
})
# Define any kwargs we're using
template_kwargs = {
'headers': {
'name': _('HTTP Header'),
'prefix': '+',
},
}
def __init__(self, targets=None, headers=None, **kwargs):
"""
Initialize Nextcloud Talk Object
"""
super(NotifyNextcloudTalk, self).__init__(**kwargs)
if self.user is None or self.password is None:
msg = 'User and password have to be specified.'
self.logger.warning(msg)
raise TypeError(msg)
self.targets = parse_list(targets)
if len(self.targets) == 0:
msg = 'At least one Nextcloud Talk Room ID must be specified.'
self.logger.warning(msg)
raise TypeError(msg)
self.headers = {}
if headers:
# Store our extra headers
self.headers.update(headers)
return
def send(self, body, title='', notify_type=NotifyType.INFO, **kwargs):
"""
Perform Nextcloud Talk Notification
"""
# Prepare our Header
headers = {
'User-Agent': self.app_id,
'OCS-APIREQUEST': 'true',
}
# Apply any/all header over-rides defined
headers.update(self.headers)
# error tracking (used for function return)
has_error = False
# Create a copy of the targets list
targets = list(self.targets)
while len(targets):
target = targets.pop(0)
# Prepare our Payload
if not body:
payload = {
'message': title if title else self.app_desc,
}
else:
payload = {
'message': title + '\r\n' + body
if title else self.app_desc + '\r\n' + body,
}
# Nextcloud Talk URL
notify_url = '{schema}://{host}'\
'/ocs/v2.php/apps/spreed/api/v1/chat/{target}'
notify_url = notify_url.format(
schema='https' if self.secure else 'http',
host=self.host if not isinstance(self.port, int)
else '{}:{}'.format(self.host, self.port),
target=target,
)
self.logger.debug(
'Nextcloud Talk POST URL: %s (cert_verify=%r)',
notify_url, self.verify_certificate)
self.logger.debug(
'Nextcloud Talk Payload: %s',
str(payload))
# Always call throttle before any remote server i/o is made
self.throttle()
try:
r = requests.post(
notify_url,
data=payload,
headers=headers,
auth=(self.user, self.password),
verify=self.verify_certificate,
timeout=self.request_timeout,
)
if r.status_code != requests.codes.created:
# We had a problem
status_str = \
NotifyNextcloudTalk.http_response_code_lookup(
r.status_code)
self.logger.warning(
'Failed to send Nextcloud Talk notification:'
'{}{}error={}.'.format(
status_str,
', ' if status_str else '',
r.status_code))
self.logger.debug(
'Response Details:\r\n{}'.format(r.content))
# track our failure
has_error = True
continue
else:
self.logger.info(
'Sent Nextcloud Talk notification.')
except requests.RequestException as e:
self.logger.warning(
'A Connection error occurred sending Nextcloud Talk '
'notification.')
self.logger.debug('Socket Exception: %s' % str(e))
# track our failure
has_error = True
continue
return not has_error
def url(self, privacy=False, *args, **kwargs):
"""
Returns the URL built dynamically based on specified arguments.
"""
# Determine Authentication
auth = '{user}:{password}@'.format(
user=NotifyNextcloudTalk.quote(self.user, safe=''),
password=self.pprint(
self.password, privacy, mode=PrivacyMode.Secret, safe=''),
)
default_port = 443 if self.secure else 80
return '{schema}://{auth}{hostname}{port}/{targets}' \
.format(
schema=self.secure_protocol
if self.secure else self.protocol,
auth=auth,
# never encode hostname since we're expecting it to be a
# valid one
hostname=self.host,
port='' if self.port is None or self.port == default_port
else ':{}'.format(self.port),
targets='/'.join([NotifyNextcloudTalk.quote(x)
for x in self.targets]),
)
@staticmethod
def parse_url(url):
"""
Parses the URL and returns enough arguments that can allow
us to re-instantiate this object.
"""
results = NotifyBase.parse_url(url)
if not results:
# We're done early as we couldn't load the results
return results
# Fetch our targets
results['targets'] = \
NotifyNextcloudTalk.split_path(results['fullpath'])
# Add our headers that the user can potentially over-ride if they
# wish to to our returned result set
results['headers'] = results['qsd+']
if results['qsd-']:
results['headers'].update(results['qsd-'])
NotifyBase.logger.deprecate(
"minus (-) based Nextcloud Talk header tokens are being "
" removed; use the plus (+) symbol instead.")
return results

@ -0,0 +1,678 @@
# MIT License
# Copyright (c) 2022 Joey Espinosa <@particledecay>
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
# The above copyright notice and this permission notice shall be included in
# all copies or substantial portions of the Software.
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.
#
# Examples:
# ntfys://my-topic
# ntfy://ntfy.local.domain/my-topic
# ntfys://ntfy.local.domain:8080/my-topic
# ntfy://ntfy.local.domain/?priority=max
import re
import requests
import six
from json import loads
from json import dumps
from os.path import basename
from .NotifyBase import NotifyBase
from ..common import NotifyType
from ..AppriseLocale import gettext_lazy as _
from ..utils import parse_list
from ..utils import is_hostname
from ..utils import is_ipaddr
from ..utils import validate_regex
from ..URLBase import PrivacyMode
from ..attachment.AttachBase import AttachBase
class NtfyMode(object):
"""
Define ntfy Notification Modes
"""
# App posts upstream to the developer API on ntfy's website
CLOUD = "cloud"
# Running a dedicated private ntfy Server
PRIVATE = "private"
NTFY_MODES = (
NtfyMode.CLOUD,
NtfyMode.PRIVATE,
)
class NtfyPriority(object):
"""
Ntfy Priority Definitions
"""
MAX = 'max'
HIGH = 'high'
NORMAL = 'default'
LOW = 'low'
MIN = 'min'
NTFY_PRIORITIES = (
NtfyPriority.MAX,
NtfyPriority.HIGH,
NtfyPriority.NORMAL,
NtfyPriority.LOW,
NtfyPriority.MIN,
)
class NotifyNtfy(NotifyBase):
"""
A wrapper for ntfy Notifications
"""
# The default descriptive name associated with the Notification
service_name = 'ntfy'
# The services URL
service_url = 'https://ntfy.sh/'
# Insecure protocol (for those self hosted requests)
protocol = 'ntfy'
# The default protocol
secure_protocol = 'ntfys'
# A URL that takes you to the setup/help of the specific protocol
setup_url = 'https://github.com/caronc/apprise/wiki/Notify_ntfy'
# Default upstream/cloud host if none is defined
cloud_notify_url = 'https://ntfy.sh'
# Message time to live (if remote client isn't around to receive it)
time_to_live = 2419200
# if our hostname matches the following we automatically enforce
# cloud mode
__auto_cloud_host = re.compile(r'ntfy\.sh', re.IGNORECASE)
# Define object templates
templates = (
'{schema}://{topic}',
'{schema}://{host}/{targets}',
'{schema}://{host}:{port}/{targets}',
'{schema}://{user}@{host}/{targets}',
'{schema}://{user}@{host}:{port}/{targets}',
'{schema}://{user}:{password}@{host}/{targets}',
'{schema}://{user}:{password}@{host}:{port}/{targets}',
)
# Define our template tokens
template_tokens = dict(NotifyBase.template_tokens, **{
'host': {
'name': _('Hostname'),
'type': 'string',
},
'port': {
'name': _('Port'),
'type': 'int',
'min': 1,
'max': 65535,
},
'user': {
'name': _('Username'),
'type': 'string',
},
'password': {
'name': _('Password'),
'type': 'string',
'private': True,
},
'topic': {
'name': _('Topic'),
'type': 'string',
'map_to': 'targets',
'regex': (r'^[a-z0-9_-]{1,64}$', 'i')
},
'targets': {
'name': _('Targets'),
'type': 'list:string',
},
})
# Define our template arguments
template_args = dict(NotifyBase.template_args, **{
'attach': {
'name': _('Attach'),
'type': 'string',
},
'filename': {
'name': _('Attach Filename'),
'type': 'string',
},
'click': {
'name': _('Click'),
'type': 'string',
},
'delay': {
'name': _('Delay'),
'type': 'string',
},
'email': {
'name': _('Email'),
'type': 'string',
},
'priority': {
'name': _('Priority'),
'type': 'choice:string',
'values': NTFY_PRIORITIES,
'default': NtfyPriority.NORMAL,
},
'tags': {
'name': _('Tags'),
'type': 'string',
},
'mode': {
'name': _('Mode'),
'type': 'choice:string',
'values': NTFY_MODES,
'default': NtfyMode.PRIVATE,
},
'to': {
'alias_of': 'targets',
},
})
def __init__(self, targets=None, attach=None, filename=None, click=None,
delay=None, email=None, priority=None, tags=None, mode=None,
**kwargs):
"""
Initialize ntfy Object
"""
super(NotifyNtfy, self).__init__(**kwargs)
# Prepare our mode
self.mode = mode.strip().lower() \
if isinstance(mode, six.string_types) \
else self.template_args['mode']['default']
if self.mode not in NTFY_MODES:
msg = 'An invalid ntfy Mode ({}) was specified.'.format(mode)
self.logger.warning(msg)
raise TypeError(msg)
# Attach a file (URL supported)
self.attach = attach
# Our filename (if defined)
self.filename = filename
# A clickthrough option for notifications
self.click = click
# Time delay for notifications (various string formats)
self.delay = delay
# An email to forward notifications to
self.email = email
# The priority of the message
if priority is None:
self.priority = self.template_args['priority']['default']
else:
self.priority = priority
if self.priority not in NTFY_PRIORITIES:
msg = 'An invalid ntfy Priority ({}) was specified.'.format(
priority)
self.logger.warning(msg)
raise TypeError(msg)
# Any optional tags to attach to the notification
self.__tags = parse_list(tags)
# Build list of topics
topics = parse_list(targets)
self.topics = []
for _topic in topics:
topic = validate_regex(
_topic, *self.template_tokens['topic']['regex'])
if not topic:
self.logger.warning(
'A specified ntfy topic ({}) is invalid and will be '
'ignored'.format(_topic))
continue
self.topics.append(topic)
return
def send(self, body, title='', notify_type=NotifyType.INFO, attach=None,
**kwargs):
"""
Perform ntfy Notification
"""
# error tracking (used for function return)
has_error = False
if not len(self.topics):
# We have nothing to notify; we're done
self.logger.warning('There are no ntfy topics to notify')
return False
# Create a copy of the subreddits list
topics = list(self.topics)
while len(topics) > 0:
# Retrieve our topic
topic = topics.pop()
if attach:
# We need to upload our payload first so that we can source it
# in remaining messages
for no, attachment in enumerate(attach):
# First message only includes the text
_body = body if not no else None
_title = title if not no else None
# Perform some simple error checking
if not attachment:
# We could not access the attachment
self.logger.error(
'Could not access attachment {}.'.format(
attachment.url(privacy=True)))
return False
self.logger.debug(
'Preparing ntfy attachment {}'.format(
attachment.url(privacy=True)))
okay, response = self._send(
topic, body=_body, title=_title, attach=attachment)
if not okay:
# We can't post our attachment; abort immediately
return False
else:
# Send our Notification Message
okay, response = self._send(topic, body=body, title=title)
if not okay:
# Mark our failure, but contiue to move on
has_error = True
return not has_error
def _send(self, topic, body=None, title=None, attach=None, **kwargs):
"""
Wrapper to the requests (post) object
"""
# Prepare our headers
headers = {
'User-Agent': self.app_id,
}
# Some default values for our request object to which we'll update
# depending on what our payload is
files = None
# See https://ntfy.sh/docs/publish/#publish-as-json
data = {}
# Posting Parameters
params = {}
auth = None
if self.mode == NtfyMode.CLOUD:
# Cloud Service
notify_url = self.cloud_notify_url
else: # NotifyNtfy.PRVATE
# Allow more settings to be applied now
if self.user:
auth = (self.user, self.password)
# Prepare our ntfy Template URL
schema = 'https' if self.secure else 'http'
notify_url = '%s://%s' % (schema, self.host)
if isinstance(self.port, int):
notify_url += ':%d' % self.port
if not attach:
headers['Content-Type'] = 'application/json'
data['topic'] = topic
virt_payload = data
else:
# Point our payload to our parameters
virt_payload = params
notify_url += '/{topic}'.format(topic=topic)
if title:
virt_payload['title'] = title
if body:
virt_payload['message'] = body
if self.priority != NtfyPriority.NORMAL:
headers['X-Priority'] = self.priority
if self.delay is not None:
headers['X-Delay'] = self.delay
if self.click is not None:
headers['X-Click'] = self.click
if self.email is not None:
headers['X-Email'] = self.email
if self.__tags:
headers['X-Tags'] = ",".join(self.__tags)
if isinstance(attach, AttachBase):
# Prepare our Header
params['filename'] = attach.name
# prepare our files object
files = {'file': (attach.name, open(attach.path, 'rb'))}
elif self.attach is not None:
data['attach'] = self.attach
if self.filename is not None:
data['filename'] = self.filename
self.logger.debug('ntfy POST URL: %s (cert_verify=%r)' % (
notify_url, self.verify_certificate,
))
self.logger.debug('ntfy Payload: %s' % str(virt_payload))
self.logger.debug('ntfy Headers: %s' % str(headers))
# Always call throttle before any remote server i/o is made
self.throttle()
# Default response type
response = None
try:
r = requests.post(
notify_url,
params=params if params else None,
data=dumps(data) if data else None,
headers=headers,
files=files,
auth=auth,
verify=self.verify_certificate,
timeout=self.request_timeout,
)
if r.status_code != requests.codes.ok:
# We had a problem
status_str = \
NotifyBase.http_response_code_lookup(r.status_code)
# set up our status code to use
status_code = r.status_code
try:
# Update our status response if we can
response = loads(r.content)
status_str = response.get('error', status_str)
status_code = \
int(response.get('code', status_code))
except (AttributeError, TypeError, ValueError):
# ValueError = r.content is Unparsable
# TypeError = r.content is None
# AttributeError = r is None
# We could not parse JSON response.
# We will just use the status we already have.
pass
self.logger.warning(
"Failed to send ntfy notification to topic '{}': "
'{}{}error={}.'.format(
topic,
status_str,
', ' if status_str else '',
status_code))
self.logger.debug(
'Response Details:\r\n{}'.format(r.content))
return False, response
# otherwise we were successful
self.logger.info(
"Sent ntfy notification to '{}'.".format(notify_url))
return True, response
except requests.RequestException as e:
self.logger.warning(
'A Connection error occurred sending ntfy:%s ' % (
notify_url) + 'notification.'
)
self.logger.debug('Socket Exception: %s' % str(e))
return False, response
except (OSError, IOError) as e:
self.logger.warning(
'An I/O error occurred while handling {}.'.format(
attach.name if isinstance(attach, AttachBase)
else virt_payload))
self.logger.debug('I/O Exception: %s' % str(e))
return False, response
finally:
# Close our file (if it's open) stored in the second element
# of our files tuple (index 1)
if files:
files['file'][1].close()
def url(self, privacy=False, *args, **kwargs):
"""
Returns the URL built dynamically based on specified arguments.
"""
default_port = 443 if self.secure else 80
params = {
'priority': self.priority,
'mode': self.mode,
}
if self.attach is not None:
params['attach'] = self.attach
if self.click is not None:
params['click'] = self.click
if self.delay is not None:
params['delay'] = self.delay
if self.email is not None:
params['email'] = self.email
if self.__tags:
params['tags'] = ','.join(self.__tags)
params.update(self.url_parameters(privacy=privacy, *args, **kwargs))
# Determine Authentication
auth = ''
if self.user and self.password:
auth = '{user}:{password}@'.format(
user=NotifyNtfy.quote(self.user, safe=''),
password=self.pprint(
self.password, privacy, mode=PrivacyMode.Secret, safe=''),
)
elif self.user:
auth = '{user}@'.format(
user=NotifyNtfy.quote(self.user, safe=''),
)
if self.mode == NtfyMode.PRIVATE:
return '{schema}://{auth}{host}{port}/{targets}?{params}'.format(
schema=self.secure_protocol if self.secure else self.protocol,
auth=auth,
host=self.host,
port='' if self.port is None or self.port == default_port
else ':{}'.format(self.port),
targets='/'.join(
[NotifyNtfy.quote(x, safe='') for x in self.topics]),
params=NotifyNtfy.urlencode(params)
)
else: # Cloud mode
return '{schema}://{targets}?{params}'.format(
schema=self.secure_protocol,
targets='/'.join(
[NotifyNtfy.quote(x, safe='') for x in self.topics]),
params=NotifyNtfy.urlencode(params)
)
@staticmethod
def parse_url(url):
"""
Parses the URL and returns enough arguments that can allow
us to re-instantiate this object.
"""
results = NotifyBase.parse_url(url, verify_host=False)
if not results:
# We're done early as we couldn't load the results
return results
if 'priority' in results['qsd'] and len(results['qsd']['priority']):
_map = {
# Supported lookups
'mi': NtfyPriority.MIN,
'1': NtfyPriority.MIN,
'l': NtfyPriority.LOW,
'2': NtfyPriority.LOW,
'n': NtfyPriority.NORMAL, # support normal keyword
'd': NtfyPriority.NORMAL, # default keyword
'3': NtfyPriority.NORMAL,
'h': NtfyPriority.HIGH,
'4': NtfyPriority.HIGH,
'ma': NtfyPriority.MAX,
'5': NtfyPriority.MAX,
}
try:
# pretty-format (and update short-format)
results['priority'] = \
_map[results['qsd']['priority'][0:2].lower()]
except KeyError:
# Pass along what was set so it can be handed during
# initialization
results['priority'] = str(results['qsd']['priority'])
pass
if 'attach' in results['qsd'] and len(results['qsd']['attach']):
results['attach'] = NotifyNtfy.unquote(results['qsd']['attach'])
_results = NotifyBase.parse_url(results['attach'])
if _results:
results['filename'] = \
None if _results['fullpath'] \
else basename(_results['fullpath'])
if 'filename' in results['qsd'] and \
len(results['qsd']['filename']):
results['filename'] = \
basename(NotifyNtfy.unquote(results['qsd']['filename']))
if 'click' in results['qsd'] and len(results['qsd']['click']):
results['click'] = NotifyNtfy.unquote(results['qsd']['click'])
if 'delay' in results['qsd'] and len(results['qsd']['delay']):
results['delay'] = NotifyNtfy.unquote(results['qsd']['delay'])
if 'email' in results['qsd'] and len(results['qsd']['email']):
results['email'] = NotifyNtfy.unquote(results['qsd']['email'])
if 'tags' in results['qsd'] and len(results['qsd']['tags']):
results['tags'] = \
parse_list(NotifyNtfy.unquote(results['qsd']['tags']))
# Acquire our targets/topics
results['targets'] = NotifyNtfy.split_path(results['fullpath'])
# The 'to' makes it easier to use yaml configuration
if 'to' in results['qsd'] and len(results['qsd']['to']):
results['targets'] += \
NotifyNtfy.parse_list(results['qsd']['to'])
# Mode override
if 'mode' in results['qsd'] and results['qsd']['mode']:
results['mode'] = NotifyNtfy.unquote(
results['qsd']['mode'].strip().lower())
else:
# We can try to detect the mode based on the validity of the
# hostname.
#
# This isn't a surfire way to do things though; it's best to
# specify the mode= flag
results['mode'] = NtfyMode.PRIVATE \
if ((is_hostname(results['host'])
or is_ipaddr(results['host'])) and results['targets']) \
else NtfyMode.CLOUD
if results['mode'] == NtfyMode.CLOUD:
# Store first entry as it can be a topic too in this case
# But only if we also rule it out not being the words
# ntfy.sh itself, something that starts wiht an non-alpha numeric
# character:
if not NotifyNtfy.__auto_cloud_host.search(results['host']):
# Add it to the front of the list for consistency
results['targets'].insert(0, results['host'])
elif results['mode'] == NtfyMode.PRIVATE and \
not (is_hostname(results['host'] or
is_ipaddr(results['host']))):
# Invalid Host for NtfyMode.PRIVATE
return None
return results
@staticmethod
def parse_native_url(url):
"""
Support https://ntfy.sh/topic
"""
# Quick lookup for users who want to just paste
# the ntfy.sh url directly into Apprise
result = re.match(
r'^(http|ntfy)s?://ntfy\.sh'
r'(?P<topics>/[^?]+)?'
r'(?P<params>\?.+)?$', url, re.I)
if result:
mode = 'mode=%s' % NtfyMode.CLOUD
return NotifyNtfy.parse_url(
'{schema}://{topics}{params}'.format(
schema=NotifyNtfy.secure_protocol,
topics=result.group('topics')
if result.group('topics') else '',
params='?%s' % mode
if not result.group('params')
else result.group('params') + '&%s' % mode))
return None

@ -161,14 +161,14 @@ class NotifyReddit(NotifyBase):
'type': 'string',
'private': True,
'required': True,
'regex': (r'^[a-z0-9-]+$', 'i'),
'regex': (r'^[a-z0-9_-]+$', 'i'),
},
'app_secret': {
'name': _('Application Secret'),
'type': 'string',
'private': True,
'required': True,
'regex': (r'^[a-z0-9-]+$', 'i'),
'regex': (r'^[a-z0-9_-]+$', 'i'),
},
'target_subreddit': {
'name': _('Target Subreddit'),
@ -465,7 +465,7 @@ class NotifyReddit(NotifyBase):
'api_type': 'json',
'extension': 'json',
'sr': subreddit,
'title': title,
'title': title if title else self.app_desc,
'kind': kind,
'nsfw': True if self.nsfw else False,
'resubmit': True if self.resubmit else False,

@ -0,0 +1,950 @@
# -*- coding: utf-8 -*-
#
# Copyright (C) 2021 Chris Caron <lead2gold@gmail.com>
# All rights reserved.
#
# This code is licensed under the MIT License.
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files(the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and / or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions :
#
# The above copyright notice and this permission notice shall be included in
# all copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
# THE SOFTWARE.
# API Information:
# - https://docs.aws.amazon.com/ses/latest/APIReference/API_SendRawEmail.html
#
# AWS Credentials (access_key and secret_access_key)
# - https://docs.aws.amazon.com/sdk-for-java/v1/developer-guide/\
# setup-credentials.html
# - https://docs.aws.amazon.com/toolkit-for-eclipse/v1/user-guide/\
# setup-credentials.html
#
# Other systems write these credentials to:
# - ~/.aws/credentials on Linux, macOS, or Unix
# - C:\Users\USERNAME\.aws\credentials on Windows
#
#
# To get A users access key ID and secret access key
#
# 1. Open the IAM console: https://console.aws.amazon.com/iam/home
# 2. On the navigation menu, choose Users.
# 3. Choose your IAM user name (not the check box).
# 4. Open the Security credentials tab, and then choose:
# Create Access key - Programmatic access
# 5. To see the new access key, choose Show. Your credentials resemble
# the following:
# Access key ID: AKIAIOSFODNN7EXAMPLE
# Secret access key: wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY
#
# To download the key pair, choose Download .csv file. Store the keys
# The account requries this permssion to 'SES v2 : SendEmail' in order to
# work
#
# To get the root users account (if you're logged in as that) you can
# visit: https://console.aws.amazon.com/iam/home#/\
# security_credentials$access_key
#
# This information is vital to work with SES
# To use/test the service, i logged into the portal via:
# - https://portal.aws.amazon.com
#
# Go to the dashboard of the Amazon SES (Simple Email Service)
# 1. You must have a verified identity; click on that option and create one
# if you don't already have one. Until it's verified, you won't be able to
# do the next step.
# 2. From here you'll be able to retrieve your ARN associated with your
# identity you want Apprise to send emails on behalf. It might look
# something like:
# arn:aws:ses:us-east-2:133216123003:identity/user@example.com
#
# This is your ARN (Amazon Record Name)
#
#
import re
import hmac
import base64
import requests
from hashlib import sha256
from datetime import datetime
from collections import OrderedDict
from xml.etree import ElementTree
from email.mime.text import MIMEText
from email.mime.application import MIMEApplication
from email.mime.multipart import MIMEMultipart
from email.utils import formataddr
from email.header import Header
try:
# Python v3.x
from urllib.parse import quote
except ImportError:
# Python v2.x
from urllib import quote
from .NotifyBase import NotifyBase
from ..URLBase import PrivacyMode
from ..common import NotifyFormat
from ..common import NotifyType
from ..utils import parse_emails
from ..utils import validate_regex
from ..AppriseLocale import gettext_lazy as _
from ..utils import is_email
# Our Regin Identifier
# support us-gov-west-1 syntax as well
IS_REGION = re.compile(
r'^\s*(?P<country>[a-z]{2})-(?P<area>[a-z-]+?)-(?P<no>[0-9]+)\s*$', re.I)
# Extend HTTP Error Messages
AWS_HTTP_ERROR_MAP = {
403: 'Unauthorized - Invalid Access/Secret Key Combination.',
}
class NotifySES(NotifyBase):
"""
A wrapper for AWS SES (Amazon Simple Email Service)
"""
# The default descriptive name associated with the Notification
service_name = 'AWS Simple Email Service (SES)'
# The services URL
service_url = 'https://aws.amazon.com/ses/'
# The default secure protocol
secure_protocol = 'ses'
# A URL that takes you to the setup/help of the specific protocol
setup_url = 'https://github.com/caronc/apprise/wiki/Notify_ses'
# AWS is pretty good for handling data load so request limits
# can occur in much shorter bursts
request_rate_per_sec = 2.5
# Default Notify Format
notify_format = NotifyFormat.HTML
# Define object templates
templates = (
'{schema}://{from_email}/{access_key_id}/{secret_access_key}/'
'{region}/{targets}',
'{schema}://{from_email}/{access_key_id}/{secret_access_key}/'
'{region}',
)
# Define our template tokens
template_tokens = dict(NotifyBase.template_tokens, **{
'from_email': {
'name': _('From Email'),
'type': 'string',
'map_to': 'from_addr',
},
'access_key_id': {
'name': _('Access Key ID'),
'type': 'string',
'private': True,
'required': True,
},
'secret_access_key': {
'name': _('Secret Access Key'),
'type': 'string',
'private': True,
'required': True,
},
'region': {
'name': _('Region'),
'type': 'string',
'regex': (r'^[a-z]{2}-[a-z-]+?-[0-9]+$', 'i'),
'map_to': 'region_name',
},
'targets': {
'name': _('Target Emails'),
'type': 'list:string',
},
})
# Define our template arguments
template_args = dict(NotifyBase.template_args, **{
'to': {
'alias_of': 'targets',
},
'from': {
'alias_of': 'from_email',
},
'reply': {
'name': _('Reply To Email'),
'type': 'string',
'map_to': 'reply_to',
},
'name': {
'name': _('From Name'),
'type': 'string',
'map_to': 'from_name',
},
'cc': {
'name': _('Carbon Copy'),
'type': 'list:string',
},
'bcc': {
'name': _('Blind Carbon Copy'),
'type': 'list:string',
},
'access': {
'alias_of': 'access_key_id',
},
'secret': {
'alias_of': 'secret_access_key',
},
'region': {
'alias_of': 'region',
},
})
def __init__(self, access_key_id, secret_access_key, region_name,
reply_to=None, from_addr=None, from_name=None, targets=None,
cc=None, bcc=None, **kwargs):
"""
Initialize Notify AWS SES Object
"""
super(NotifySES, self).__init__(**kwargs)
# Store our AWS API Access Key
self.aws_access_key_id = validate_regex(access_key_id)
if not self.aws_access_key_id:
msg = 'An invalid AWS Access Key ID was specified.'
self.logger.warning(msg)
raise TypeError(msg)
# Store our AWS API Secret Access key
self.aws_secret_access_key = validate_regex(secret_access_key)
if not self.aws_secret_access_key:
msg = 'An invalid AWS Secret Access Key ' \
'({}) was specified.'.format(secret_access_key)
self.logger.warning(msg)
raise TypeError(msg)
# Acquire our AWS Region Name:
# eg. us-east-1, cn-north-1, us-west-2, ...
self.aws_region_name = validate_regex(
region_name, *self.template_tokens['region']['regex'])
if not self.aws_region_name:
msg = 'An invalid AWS Region ({}) was specified.'.format(
region_name)
self.logger.warning(msg)
raise TypeError(msg)
# Acquire Email 'To'
self.targets = list()
# Acquire Carbon Copies
self.cc = set()
# Acquire Blind Carbon Copies
self.bcc = set()
# For tracking our email -> name lookups
self.names = {}
# Set our notify_url based on our region
self.notify_url = 'https://email.{}.amazonaws.com'\
.format(self.aws_region_name)
# AWS Service Details
self.aws_service_name = 'ses'
self.aws_canonical_uri = '/'
# AWS Authentication Details
self.aws_auth_version = 'AWS4'
self.aws_auth_algorithm = 'AWS4-HMAC-SHA256'
self.aws_auth_request = 'aws4_request'
# Get our From username (if specified)
self.from_name = from_name
if from_addr:
self.from_addr = from_addr
else:
# Get our from email address
self.from_addr = '{user}@{host}'.format(
user=self.user, host=self.host) if self.user else None
if not (self.from_addr and is_email(self.from_addr)):
msg = 'An invalid AWS From ({}) was specified.'.format(
'{user}@{host}'.format(user=self.user, host=self.host))
self.logger.warning(msg)
raise TypeError(msg)
self.reply_to = None
if reply_to:
result = is_email(reply_to)
if not result:
msg = 'An invalid AWS Reply To ({}) was specified.'.format(
'{user}@{host}'.format(user=self.user, host=self.host))
self.logger.warning(msg)
raise TypeError(msg)
self.reply_to = (
result['name'] if result['name'] else False,
result['full_email'])
if targets:
# Validate recipients (to:) and drop bad ones:
for recipient in parse_emails(targets):
result = is_email(recipient)
if result:
self.targets.append(
(result['name'] if result['name'] else False,
result['full_email']))
continue
self.logger.warning(
'Dropped invalid To email '
'({}) specified.'.format(recipient),
)
else:
# If our target email list is empty we want to add ourselves to it
self.targets.append(
(self.from_name if self.from_name else False, self.from_addr))
# Validate recipients (cc:) and drop bad ones:
for recipient in parse_emails(cc):
email = is_email(recipient)
if email:
self.cc.add(email['full_email'])
# Index our name (if one exists)
self.names[email['full_email']] = \
email['name'] if email['name'] else False
continue
self.logger.warning(
'Dropped invalid Carbon Copy email '
'({}) specified.'.format(recipient),
)
# Validate recipients (bcc:) and drop bad ones:
for recipient in parse_emails(bcc):
email = is_email(recipient)
if email:
self.bcc.add(email['full_email'])
# Index our name (if one exists)
self.names[email['full_email']] = \
email['name'] if email['name'] else False
continue
self.logger.warning(
'Dropped invalid Blind Carbon Copy email '
'({}) specified.'.format(recipient),
)
return
def send(self, body, title='', notify_type=NotifyType.INFO, attach=None,
**kwargs):
"""
wrapper to send_notification since we can alert more then one channel
"""
if not self.targets:
# There is no one to email; we're done
self.logger.warning(
'There are no SES email recipients to notify')
return False
# error tracking (used for function return)
has_error = False
# Initialize our default from name
from_name = self.from_name if self.from_name \
else self.reply_to[0] if self.reply_to and \
self.reply_to[0] else self.app_desc
reply_to = (
from_name, self.from_addr
if not self.reply_to else self.reply_to[1])
# Create a copy of the targets list
emails = list(self.targets)
while len(emails):
# Get our email to notify
to_name, to_addr = emails.pop(0)
# Strip target out of cc list if in To or Bcc
cc = (self.cc - self.bcc - set([to_addr]))
# Strip target out of bcc list if in To
bcc = (self.bcc - set([to_addr]))
try:
# Format our cc addresses to support the Name field
cc = [formataddr(
(self.names.get(addr, False), addr), charset='utf-8')
for addr in cc]
# Format our bcc addresses to support the Name field
bcc = [formataddr(
(self.names.get(addr, False), addr), charset='utf-8')
for addr in bcc]
except TypeError:
# Python v2.x Support (no charset keyword)
# Format our cc addresses to support the Name field
cc = [formataddr( # pragma: no branch
(self.names.get(addr, False), addr)) for addr in cc]
# Format our bcc addresses to support the Name field
bcc = [formataddr( # pragma: no branch
(self.names.get(addr, False), addr)) for addr in bcc]
self.logger.debug('Email From: {} <{}>'.format(
quote(reply_to[0], ' '),
quote(reply_to[1], '@ ')))
self.logger.debug('Email To: {}'.format(to_addr))
if cc:
self.logger.debug('Email Cc: {}'.format(', '.join(cc)))
if bcc:
self.logger.debug('Email Bcc: {}'.format(', '.join(bcc)))
# Prepare Email Message
if self.notify_format == NotifyFormat.HTML:
content = MIMEText(body, 'html', 'utf-8')
else:
content = MIMEText(body, 'plain', 'utf-8')
# Create a Multipart container if there is an attachment
base = MIMEMultipart() if attach else content
base['Subject'] = Header(title, 'utf-8')
try:
base['From'] = formataddr(
(from_name if from_name else False, self.from_addr),
charset='utf-8')
base['To'] = formataddr((to_name, to_addr), charset='utf-8')
if reply_to[1] != self.from_addr:
base['Reply-To'] = formataddr(reply_to, charset='utf-8')
except TypeError:
# Python v2.x Support (no charset keyword)
base['From'] = formataddr(
(from_name if from_name else False, self.from_addr))
base['To'] = formataddr((to_name, to_addr))
if reply_to[1] != self.from_addr:
base['Reply-To'] = formataddr(reply_to)
base['Cc'] = ','.join(cc)
base['Date'] = \
datetime.utcnow().strftime("%a, %d %b %Y %H:%M:%S +0000")
base['X-Application'] = self.app_id
if attach:
# First attach our body to our content as the first element
base.attach(content)
# Now store our attachments
for attachment in attach:
if not attachment:
# We could not load the attachment; take an early
# exit since this isn't what the end user wanted
# We could not access the attachment
self.logger.error(
'Could not access attachment {}.'.format(
attachment.url(privacy=True)))
return False
self.logger.debug(
'Preparing Email attachment {}'.format(
attachment.url(privacy=True)))
with open(attachment.path, "rb") as abody:
app = MIMEApplication(abody.read())
app.set_type(attachment.mimetype)
app.add_header(
'Content-Disposition',
'attachment; filename="{}"'.format(
Header(attachment.name, 'utf-8')),
)
base.attach(app)
# Prepare our payload object
payload = {
'Action': 'SendRawEmail',
'Version': '2010-12-01',
'RawMessage.Data': base64.b64encode(
base.as_string().encode('utf-8')).decode('utf-8')
}
for no, email in enumerate(([to_addr] + bcc + cc), start=1):
payload['Destinations.member.{}'.format(no)] = email
# Specify from address
payload['Source'] = '{} <{}>'.format(
quote(from_name, ' '),
quote(self.from_addr, '@ '))
(result, response) = self._post(payload=payload, to=to_addr)
if not result:
# Mark our failure
has_error = True
continue
return not has_error
def _post(self, payload, to):
"""
Wrapper to request.post() to manage it's response better and make
the send() function cleaner and easier to maintain.
This function returns True if the _post was successful and False
if it wasn't.
"""
# Always call throttle before any remote server i/o is made; for AWS
# time plays a huge factor in the headers being sent with the payload.
# So for AWS (SES) requests we must throttle before they're generated
# and not directly before the i/o call like other notification
# services do.
self.throttle()
# Convert our payload from a dict() into a urlencoded string
payload = NotifySES.urlencode(payload)
# Prepare our Notification URL
# Prepare our AWS Headers based on our payload
headers = self.aws_prepare_request(payload)
self.logger.debug('AWS SES POST URL: %s (cert_verify=%r)' % (
self.notify_url, self.verify_certificate,
))
self.logger.debug('AWS SES Payload (%d bytes)', len(payload))
try:
r = requests.post(
self.notify_url,
data=payload,
headers=headers,
verify=self.verify_certificate,
timeout=self.request_timeout,
)
if r.status_code != requests.codes.ok:
# We had a problem
status_str = \
NotifySES.http_response_code_lookup(
r.status_code, AWS_HTTP_ERROR_MAP)
self.logger.warning(
'Failed to send AWS SES notification to {}: '
'{}{}error={}.'.format(
to,
status_str,
', ' if status_str else '',
r.status_code))
self.logger.debug('Response Details:\r\n{}'.format(r.content))
return (False, NotifySES.aws_response_to_dict(r.text))
else:
self.logger.info(
'Sent AWS SES notification to "%s".' % (to))
except requests.RequestException as e:
self.logger.warning(
'A Connection error occurred sending AWS SES '
'notification to "%s".' % (to),
)
self.logger.debug('Socket Exception: %s' % str(e))
return (False, NotifySES.aws_response_to_dict(None))
return (True, NotifySES.aws_response_to_dict(r.text))
def aws_prepare_request(self, payload, reference=None):
"""
Takes the intended payload and returns the headers for it.
The payload is presumed to have been already urlencoded()
"""
# Define our AWS SES header
headers = {
'User-Agent': self.app_id,
'Content-Type': 'application/x-www-form-urlencoded; charset=utf-8',
# Populated below
'Content-Length': 0,
'Authorization': None,
'X-Amz-Date': None,
}
# Get a reference time (used for header construction)
reference = datetime.utcnow()
# Provide Content-Length
headers['Content-Length'] = str(len(payload))
# Amazon Date Format
amzdate = reference.strftime('%Y%m%dT%H%M%SZ')
headers['X-Amz-Date'] = amzdate
# Credential Scope
scope = '{date}/{region}/{service}/{request}'.format(
date=reference.strftime('%Y%m%d'),
region=self.aws_region_name,
service=self.aws_service_name,
request=self.aws_auth_request,
)
# Similar to headers; but a subset. keys must be lowercase
signed_headers = OrderedDict([
('content-type', headers['Content-Type']),
('host', 'email.{region}.amazonaws.com'.format(
region=self.aws_region_name)),
('x-amz-date', headers['X-Amz-Date']),
])
#
# Build Canonical Request Object
#
canonical_request = '\n'.join([
# Method
u'POST',
# URL
self.aws_canonical_uri,
# Query String (none set for POST)
'',
# Header Content (must include \n at end!)
# All entries except characters in amazon date must be
# lowercase
'\n'.join(['%s:%s' % (k, v)
for k, v in signed_headers.items()]) + '\n',
# Header Entries (in same order identified above)
';'.join(signed_headers.keys()),
# Payload
sha256(payload.encode('utf-8')).hexdigest(),
])
# Prepare Unsigned Signature
to_sign = '\n'.join([
self.aws_auth_algorithm,
amzdate,
scope,
sha256(canonical_request.encode('utf-8')).hexdigest(),
])
# Our Authorization header
headers['Authorization'] = ', '.join([
'{algorithm} Credential={key}/{scope}'.format(
algorithm=self.aws_auth_algorithm,
key=self.aws_access_key_id,
scope=scope,
),
'SignedHeaders={signed_headers}'.format(
signed_headers=';'.join(signed_headers.keys()),
),
'Signature={signature}'.format(
signature=self.aws_auth_signature(to_sign, reference)
),
])
return headers
def aws_auth_signature(self, to_sign, reference):
"""
Generates a AWS v4 signature based on provided payload
which should be in the form of a string.
"""
def _sign(key, msg, to_hex=False):
"""
Perform AWS Signing
"""
if to_hex:
return hmac.new(key, msg.encode('utf-8'), sha256).hexdigest()
return hmac.new(key, msg.encode('utf-8'), sha256).digest()
_date = _sign((
self.aws_auth_version +
self.aws_secret_access_key).encode('utf-8'),
reference.strftime('%Y%m%d'))
_region = _sign(_date, self.aws_region_name)
_service = _sign(_region, self.aws_service_name)
_signed = _sign(_service, self.aws_auth_request)
return _sign(_signed, to_sign, to_hex=True)
@staticmethod
def aws_response_to_dict(aws_response):
"""
Takes an AWS Response object as input and returns it as a dictionary
but not befor extracting out what is useful to us first.
eg:
IN:
<SendRawEmailResponse
xmlns="http://ses.amazonaws.com/doc/2010-12-01/">
<SendRawEmailResult>
<MessageId>
010f017d87656ee2-a2ea291f-79ea-
44f3-9d25-00d041de3007-000000</MessageId>
</SendRawEmailResult>
<ResponseMetadata>
<RequestId>7abb454e-904b-4e46-a23c-2f4d2fc127a6</RequestId>
</ResponseMetadata>
</SendRawEmailResponse>
OUT:
{
'type': 'SendRawEmailResponse',
'message_id': '010f017d87656ee2-a2ea291f-79ea-
44f3-9d25-00d041de3007-000000',
'request_id': '7abb454e-904b-4e46-a23c-2f4d2fc127a6',
}
"""
# Define ourselves a set of directives we want to keep if found and
# then identify the value we want to map them to in our response
# object
aws_keep_map = {
'RequestId': 'request_id',
'MessageId': 'message_id',
# Error Message Handling
'Type': 'error_type',
'Code': 'error_code',
'Message': 'error_message',
}
# A default response object that we'll manipulate as we pull more data
# from our AWS Response object
response = {
'type': None,
'request_id': None,
'message_id': None,
}
try:
# we build our tree, but not before first eliminating any
# reference to namespacing (if present) as it makes parsing
# the tree so much easier.
root = ElementTree.fromstring(
re.sub(' xmlns="[^"]+"', '', aws_response, count=1))
# Store our response tag object name
response['type'] = str(root.tag)
def _xml_iter(root, response):
if len(root) > 0:
for child in root:
# use recursion to parse everything
_xml_iter(child, response)
elif root.tag in aws_keep_map.keys():
response[aws_keep_map[root.tag]] = (root.text).strip()
# Recursivly iterate over our AWS Response to extract the
# fields we're interested in in efforts to populate our response
# object.
_xml_iter(root, response)
except (ElementTree.ParseError, TypeError):
# bad data just causes us to generate a bad response
pass
return response
def url(self, privacy=False, *args, **kwargs):
"""
Returns the URL built dynamically based on specified arguments.
"""
# Acquire any global URL parameters
params = self.url_parameters(privacy=privacy, *args, **kwargs)
if self.from_name is not None:
# from_name specified; pass it back on the url
params['name'] = self.from_name
if self.cc:
# Handle our Carbon Copy Addresses
params['cc'] = ','.join(
['{}{}'.format(
'' if not e not in self.names
else '{}:'.format(self.names[e]), e) for e in self.cc])
if self.bcc:
# Handle our Blind Carbon Copy Addresses
params['bcc'] = ','.join(self.bcc)
if self.reply_to:
# Handle our reply to address
params['reply'] = '{} <{}>'.format(*self.reply_to) \
if self.reply_to[0] else self.reply_to[1]
# a simple boolean check as to whether we display our target emails
# or not
has_targets = \
not (len(self.targets) == 1
and self.targets[0][1] == self.from_addr)
return '{schema}://{from_addr}/{key_id}/{key_secret}/{region}/' \
'{targets}/?{params}'.format(
schema=self.secure_protocol,
from_addr=NotifySES.quote(self.from_addr, safe='@'),
key_id=self.pprint(self.aws_access_key_id, privacy, safe=''),
key_secret=self.pprint(
self.aws_secret_access_key, privacy,
mode=PrivacyMode.Secret, safe=''),
region=NotifySES.quote(self.aws_region_name, safe=''),
targets='' if not has_targets else '/'.join(
[NotifySES.quote('{}{}'.format(
'' if not e[0] else '{}:'.format(e[0]), e[1]),
safe='') for e in self.targets]),
params=NotifySES.urlencode(params),
)
@staticmethod
def parse_url(url):
"""
Parses the URL and returns enough arguments that can allow
us to re-instantiate this object.
"""
results = NotifyBase.parse_url(url, verify_host=False)
if not results:
# We're done early as we couldn't load the results
return results
# Get our entries; split_path() looks after unquoting content for us
# by default
entries = NotifySES.split_path(results['fullpath'])
# The AWS Access Key ID is stored in the first entry
access_key_id = entries.pop(0) if entries else None
# Our AWS Access Key Secret contains slashes in it which unfortunately
# means it is of variable length after the hostname. Since we require
# that the user provides the region code, we intentionally use this
# as our delimiter to detect where our Secret is.
secret_access_key = None
region_name = None
# We need to iterate over each entry in the fullpath and find our
# region. Once we get there we stop and build our secret from our
# accumulated data.
secret_access_key_parts = list()
# Section 1: Get Region and Access Secret
index = 0
for index, entry in enumerate(entries, start=1):
# Are we at the region yet?
result = IS_REGION.match(entry)
if result:
# Ensure region is nicely formatted
region_name = "{country}-{area}-{no}".format(
country=result.group('country').lower(),
area=result.group('area').lower(),
no=result.group('no'),
)
# We're done with Section 1 of our url (the credentials)
break
elif is_email(entry):
# We're done with Section 1 of our url (the credentials)
index -= 1
break
# Store our secret parts
secret_access_key_parts.append(entry)
# Prepare our Secret Access Key
secret_access_key = '/'.join(secret_access_key_parts) \
if secret_access_key_parts else None
# Section 2: Get our Recipients (basically all remaining entries)
results['targets'] = entries[index:]
if 'name' in results['qsd'] and len(results['qsd']['name']):
# Extract from name to associate with from address
results['from_name'] = \
NotifySES.unquote(results['qsd']['name'])
# Handle 'to' email address
if 'to' in results['qsd'] and len(results['qsd']['to']):
results['targets'].append(results['qsd']['to'])
# Handle Carbon Copy Addresses
if 'cc' in results['qsd'] and len(results['qsd']['cc']):
results['cc'] = NotifySES.parse_list(results['qsd']['cc'])
# Handle Blind Carbon Copy Addresses
if 'bcc' in results['qsd'] and len(results['qsd']['bcc']):
results['bcc'] = NotifySES.parse_list(results['qsd']['bcc'])
# Handle From Address handling
if 'from' in results['qsd'] and len(results['qsd']['from']):
results['from_addr'] = \
NotifySES.unquote(results['qsd']['from'])
# Handle Reply To Address
if 'reply' in results['qsd'] and len(results['qsd']['reply']):
results['reply_to'] = \
NotifySES.unquote(results['qsd']['reply'])
# Handle secret_access_key over-ride
if 'secret' in results['qsd'] and len(results['qsd']['secret']):
results['secret_access_key'] = \
NotifySES.unquote(results['qsd']['secret'])
else:
results['secret_access_key'] = secret_access_key
# Handle access key id over-ride
if 'access' in results['qsd'] and len(results['qsd']['access']):
results['access_key_id'] = \
NotifySES.unquote(results['qsd']['access'])
else:
results['access_key_id'] = access_key_id
# Handle region name id over-ride
if 'region' in results['qsd'] and len(results['qsd']['region']):
results['region_name'] = \
NotifySES.unquote(results['qsd']['region'])
else:
results['region_name'] = region_name
# Return our result set
return results

@ -56,7 +56,7 @@ IS_TOPIC = re.compile(r'^#?(?P<name>[A-Za-z0-9_-]+)\s*$')
# users of this product search though this Access Key Secret and escape all
# of the forward slashes!
IS_REGION = re.compile(
r'^\s*(?P<country>[a-z]{2})-(?P<area>[a-z]+)-(?P<no>[0-9]+)\s*$', re.I)
r'^\s*(?P<country>[a-z]{2})-(?P<area>[a-z-]+?)-(?P<no>[0-9]+)\s*$', re.I)
# Extend HTTP Error Messages
AWS_HTTP_ERROR_MAP = {
@ -116,7 +116,7 @@ class NotifySNS(NotifyBase):
'name': _('Region'),
'type': 'string',
'required': True,
'regex': (r'^[a-z]{2}-[a-z]+-[0-9]+$', 'i'),
'regex': (r'^[a-z]{2}-[a-z-]+?-[0-9]+$', 'i'),
'map_to': 'region_name',
},
'target_phone_no': {
@ -143,6 +143,15 @@ class NotifySNS(NotifyBase):
'to': {
'alias_of': 'targets',
},
'access': {
'alias_of': 'access_key_id',
},
'secret': {
'alias_of': 'secret_access_key',
},
'region': {
'alias_of': 'region',
},
})
def __init__(self, access_key_id, secret_access_key, region_name,
@ -200,8 +209,8 @@ class NotifySNS(NotifyBase):
for target in parse_list(targets):
result = is_phone_no(target)
if result:
# store valid phone number
self.phone.append('+{}'.format(result))
# store valid phone number in E.164 format
self.phone.append('+{}'.format(result['full']))
continue
result = IS_TOPIC.match(target)
@ -576,8 +585,8 @@ class NotifySNS(NotifyBase):
region=NotifySNS.quote(self.aws_region_name, safe=''),
targets='/'.join(
[NotifySNS.quote(x) for x in chain(
# Phone # are prefixed with a plus symbol
['+{}'.format(x) for x in self.phone],
# Phone # are already prefixed with a plus symbol
self.phone,
# Topics are prefixed with a pound/hashtag symbol
['#{}'.format(x) for x in self.topics],
)]),
@ -651,10 +660,26 @@ class NotifySNS(NotifyBase):
results['targets'] += \
NotifySNS.parse_list(results['qsd']['to'])
# Store our other detected data (if at all)
results['region_name'] = region_name
results['access_key_id'] = access_key_id
results['secret_access_key'] = secret_access_key
# Handle secret_access_key over-ride
if 'secret' in results['qsd'] and len(results['qsd']['secret']):
results['secret_access_key'] = \
NotifySNS.unquote(results['qsd']['secret'])
else:
results['secret_access_key'] = secret_access_key
# Handle access key id over-ride
if 'access' in results['qsd'] and len(results['qsd']['access']):
results['access_key_id'] = \
NotifySNS.unquote(results['qsd']['access'])
else:
results['access_key_id'] = access_key_id
# Handle region name id over-ride
if 'region' in results['qsd'] and len(results['qsd']['region']):
results['region_name'] = \
NotifySNS.unquote(results['qsd']['region'])
else:
results['region_name'] = region_name
# Return our result set
return results

@ -0,0 +1,173 @@
# -*- coding: utf-8 -*-
#
# Copyright (C) 2020 Chris Caron <xuzheliang135@qq.com>
# All rights reserved.
#
# This code is licensed under the MIT License.
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files(the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and / or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions :
#
# The above copyright notice and this permission notice shall be included in
# all copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
# THE SOFTWARE.
import re
import requests
from ..common import NotifyType
from .NotifyBase import NotifyBase
from ..utils import validate_regex
from ..AppriseLocale import gettext_lazy as _
# Register at https://sct.ftqq.com/
# - do as the page describe and you will get the token
# Syntax:
# schan://{access_token}/
class NotifyServerChan(NotifyBase):
"""
A wrapper for ServerChan Notifications
"""
# The default descriptive name associated with the Notification
service_name = 'ServerChan'
# The services URL
service_url = 'https://sct.ftqq.com/'
# All notification requests are secure
secure_protocol = 'schan'
# A URL that takes you to the setup/help of the specific protocol
setup_url = 'https://github.com/caronc/apprise/wiki/Notify_serverchan'
# ServerChan API
notify_url = 'https://sctapi.ftqq.com/{token}.send'
# Define object templates
templates = (
'{schema}://{token}/',
)
# Define our template tokens
template_tokens = dict(NotifyBase.template_tokens, **{
'token': {
'name': _('Token'),
'type': 'string',
'private': True,
'required': True,
'regex': (r'^[a-z0-9]+$', 'i'),
},
})
def __init__(self, token, **kwargs):
"""
Initialize ServerChan Object
"""
super(NotifyServerChan, self).__init__(**kwargs)
# Token (associated with project)
self.token = validate_regex(
token, *self.template_tokens['token']['regex'])
if not self.token:
msg = 'An invalid ServerChan API Token ' \
'({}) was specified.'.format(token)
self.logger.warning(msg)
raise TypeError(msg)
def send(self, body, title='', notify_type=NotifyType.INFO, **kwargs):
"""
Perform ServerChan Notification
"""
payload = {
'title': title,
'desp': body,
}
# Our Notification URL
notify_url = self.notify_url.format(token=self.token)
# Some Debug Logging
self.logger.debug('ServerChan URL: {} (cert_verify={})'.format(
notify_url, self.verify_certificate))
self.logger.debug('ServerChan Payload: {}'.format(payload))
# Always call throttle before any remote server i/o is made
self.throttle()
try:
r = requests.post(
notify_url,
data=payload,
)
if r.status_code != requests.codes.ok:
# We had a problem
status_str = \
NotifyServerChan.http_response_code_lookup(
r.status_code)
self.logger.warning(
'Failed to send ServerChan notification: '
'{}{}error={}.'.format(
status_str,
', ' if status_str else '',
r.status_code))
self.logger.debug(
'Response Details:\r\n{}'.format(r.content))
return False
else:
self.logger.info('Sent ServerChan notification.')
except requests.RequestException as e:
self.logger.warning(
'A Connection error occured sending ServerChan '
'notification.'
)
self.logger.debug('Socket Exception: %s' % str(e))
return False
return True
def url(self, privacy=False):
"""
Returns the URL built dynamically based on specified arguments.
"""
return '{schema}://{token}'.format(
schema=self.secure_protocol,
token=self.pprint(self.token, privacy, safe=''))
@staticmethod
def parse_url(url):
"""
Parses the URL and returns enough arguments that can allow
us to substantiate this object.
"""
results = NotifyBase.parse_url(url, verify_host=False)
if not results:
# We're done early as we couldn't parse the URL
return results
pattern = 'schan://([a-zA-Z0-9]+)/' + \
('?' if not url.endswith('/') else '')
result = re.match(pattern, url)
results['token'] = result.group(1) if result else ''
return results

@ -0,0 +1,400 @@
# -*- coding: utf-8 -*-
#
# Copyright (C) 2020 Chris Caron <lead2gold@gmail.com>
# All rights reserved.
#
# This code is licensed under the MIT License.
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files(the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and / or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions :
#
# The above copyright notice and this permission notice shall be included in
# all copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
# THE SOFTWARE.
import requests
from json import dumps
from .NotifyBase import NotifyBase
from ..common import NotifyType
from ..utils import is_phone_no
from ..utils import parse_phone_no
from ..utils import parse_bool
from ..URLBase import PrivacyMode
from ..AppriseLocale import gettext_lazy as _
class NotifySignalAPI(NotifyBase):
"""
A wrapper for SignalAPI Notifications
"""
# The default descriptive name associated with the Notification
service_name = 'Signal API'
# The services URL
service_url = 'https://bbernhard.github.io/signal-cli-rest-api/'
# The default protocol
protocol = 'signal'
# The default protocol
secure_protocol = 'signals'
# A URL that takes you to the setup/help of the specific protocol
setup_url = 'https://github.com/caronc/apprise/wiki/Notify_signal'
# The maximum targets to include when doing batch transfers
default_batch_size = 10
# We don't support titles for Signal notifications
title_maxlen = 0
# Define object templates
templates = (
'{schema}://{host}/{from_phone}',
'{schema}://{host}:{port}/{from_phone}',
'{schema}://{user}@{host}/{from_phone}',
'{schema}://{user}@{host}:{port}/{from_phone}',
'{schema}://{user}:{password}@{host}/{from_phone}',
'{schema}://{user}:{password}@{host}:{port}/{from_phone}',
'{schema}://{host}/{from_phone}/{targets}',
'{schema}://{host}:{port}/{from_phone}/{targets}',
'{schema}://{user}@{host}/{from_phone}/{targets}',
'{schema}://{user}@{host}:{port}/{from_phone}/{targets}',
'{schema}://{user}:{password}@{host}/{from_phone}/{targets}',
'{schema}://{user}:{password}@{host}:{port}/{from_phone}/{targets}',
)
# Define our template tokens
template_tokens = dict(NotifyBase.template_tokens, **{
'host': {
'name': _('Hostname'),
'type': 'string',
'required': True,
},
'port': {
'name': _('Port'),
'type': 'int',
'min': 1,
'max': 65535,
},
'user': {
'name': _('Username'),
'type': 'string',
},
'password': {
'name': _('Password'),
'type': 'string',
'private': True,
},
'from_phone': {
'name': _('From Phone No'),
'type': 'string',
'required': True,
'regex': (r'^\+?[0-9\s)(+-]+$', 'i'),
'map_to': 'source',
},
'target_phone': {
'name': _('Target Phone No'),
'type': 'string',
'prefix': '+',
'regex': (r'^[0-9\s)(+-]+$', 'i'),
'map_to': 'targets',
},
'targets': {
'name': _('Targets'),
'type': 'list:string',
}
})
# Define our template arguments
template_args = dict(NotifyBase.template_args, **{
'to': {
'alias_of': 'targets',
},
'from': {
'alias_of': 'from_phone',
},
'batch': {
'name': _('Batch Mode'),
'type': 'bool',
'default': False,
},
'status': {
'name': _('Show Status'),
'type': 'bool',
'default': False,
},
})
def __init__(self, source=None, targets=None, batch=False, status=False,
**kwargs):
"""
Initialize SignalAPI Object
"""
super(NotifySignalAPI, self).__init__(**kwargs)
# Prepare Batch Mode Flag
self.batch = batch
# Set Status type
self.status = status
# Parse our targets
self.targets = list()
# Used for URL generation afterwards only
self.invalid_targets = list()
# Manage our Source Phone
result = is_phone_no(source)
if not result:
msg = 'An invalid Signal API Source Phone No ' \
'({}) was provided.'.format(source)
self.logger.warning(msg)
raise TypeError(msg)
self.source = '+{}'.format(result['full'])
if targets:
# Validate our targerts
for target in parse_phone_no(targets):
# Validate targets and drop bad ones:
result = is_phone_no(target)
if not result:
self.logger.warning(
'Dropped invalid phone # '
'({}) specified.'.format(target),
)
self.invalid_targets.append(target)
continue
# store valid phone number
self.targets.append('+{}'.format(result['full']))
else:
# Send a message to ourselves
self.targets.append(self.source)
return
def send(self, body, title='', notify_type=NotifyType.INFO, **kwargs):
"""
Perform Signal API Notification
"""
if len(self.targets) == 0:
# There were no services to notify
self.logger.warning(
'There were no Signal API targets to notify.')
return False
# error tracking (used for function return)
has_error = False
# Prepare our headers
headers = {
'User-Agent': self.app_id,
'Content-Type': 'application/json',
}
# Prepare our payload
payload = {
'message': "{}{}".format(
'' if not self.status else '{} '.format(
self.asset.ascii(notify_type)), body),
"number": self.source,
"recipients": []
}
# Determine Authentication
auth = None
if self.user:
auth = (self.user, self.password)
# Set our schema
schema = 'https' if self.secure else 'http'
# Construct our URL
notify_url = '%s://%s' % (schema, self.host)
if isinstance(self.port, int):
notify_url += ':%d' % self.port
notify_url += '/v2/send'
# Send in batches if identified to do so
batch_size = 1 if not self.batch else self.default_batch_size
for index in range(0, len(self.targets), batch_size):
# Prepare our recipients
payload['recipients'] = self.targets[index:index + batch_size]
self.logger.debug('Signal API POST URL: %s (cert_verify=%r)' % (
notify_url, self.verify_certificate,
))
self.logger.debug('Signal API Payload: %s' % str(payload))
# Always call throttle before any remote server i/o is made
self.throttle()
try:
r = requests.post(
notify_url,
auth=auth,
data=dumps(payload),
headers=headers,
verify=self.verify_certificate,
timeout=self.request_timeout,
)
if r.status_code != requests.codes.ok:
# We had a problem
status_str = \
NotifySignalAPI.http_response_code_lookup(
r.status_code)
self.logger.warning(
'Failed to send {} Signal API notification{}: '
'{}{}error={}.'.format(
len(self.targets[index:index + batch_size]),
' to {}'.format(self.targets[index])
if batch_size == 1 else '(s)',
status_str,
', ' if status_str else '',
r.status_code))
self.logger.debug(
'Response Details:\r\n{}'.format(r.content))
# Mark our failure
has_error = True
continue
else:
self.logger.info(
'Sent {} Signal API notification{}.'
.format(
len(self.targets[index:index + batch_size]),
' to {}'.format(self.targets[index])
if batch_size == 1 else '(s)',
))
except requests.RequestException as e:
self.logger.warning(
'A Connection error occured sending {} Signal API '
'notification(s).'.format(
len(self.targets[index:index + batch_size])))
self.logger.debug('Socket Exception: %s' % str(e))
# Mark our failure
has_error = True
continue
return not has_error
def url(self, privacy=False, *args, **kwargs):
"""
Returns the URL built dynamically based on specified arguments.
"""
# Define any URL parameters
params = {
'batch': 'yes' if self.batch else 'no',
'status': 'yes' if self.status else 'no',
}
# Extend our parameters
params.update(self.url_parameters(privacy=privacy, *args, **kwargs))
# Determine Authentication
auth = ''
if self.user and self.password:
auth = '{user}:{password}@'.format(
user=NotifySignalAPI.quote(self.user, safe=''),
password=self.pprint(
self.password, privacy, mode=PrivacyMode.Secret, safe=''),
)
elif self.user:
auth = '{user}@'.format(
user=NotifySignalAPI.quote(self.user, safe=''),
)
default_port = 443 if self.secure else 80
# So we can strip out our own phone (if present); create a copy of our
# targets
if len(self.targets) == 1 and self.source in self.targets:
targets = []
elif len(self.targets) == 0:
# invalid phone-no were specified
targets = self.invalid_targets
else:
targets = list(self.targets)
return '{schema}://{auth}{hostname}{port}/{src}/{dst}?{params}'.format(
schema=self.secure_protocol if self.secure else self.protocol,
auth=auth,
# never encode hostname since we're expecting it to be a valid one
hostname=self.host,
port='' if self.port is None or self.port == default_port
else ':{}'.format(self.port),
src=self.source,
dst='/'.join(
[NotifySignalAPI.quote(x, safe='') for x in targets]),
params=NotifySignalAPI.urlencode(params),
)
@staticmethod
def parse_url(url):
"""
Parses the URL and returns enough arguments that can allow
us to re-instantiate this object.
"""
results = NotifyBase.parse_url(url, verify_host=False)
if not results:
# We're done early as we couldn't load the results
return results
# Get our entries; split_path() looks after unquoting content for us
# by default
results['targets'] = \
NotifySignalAPI.split_path(results['fullpath'])
# The hostname is our authentication key
results['apikey'] = NotifySignalAPI.unquote(results['host'])
if 'from' in results['qsd'] and len(results['qsd']['from']):
results['source'] = \
NotifySignalAPI.unquote(results['qsd']['from'])
elif results['targets']:
# The from phone no is the first entry in the list otherwise
results['source'] = results['targets'].pop(0)
# Support the 'to' variable so that we can support targets this way too
# The 'to' makes it easier to use yaml configuration
if 'to' in results['qsd'] and len(results['qsd']['to']):
results['targets'] += \
NotifySignalAPI.parse_phone_no(results['qsd']['to'])
# Get Batch Mode Flag
results['batch'] = \
parse_bool(results['qsd'].get('batch', False))
# Get status switch
results['status'] = \
parse_bool(results['qsd'].get('status', False))
return results

@ -316,10 +316,6 @@ class NotifySlack(NotifyBase):
self.logger.warning(msg)
raise TypeError(msg)
if not self.user:
self.logger.warning(
'No user was specified; using "%s".' % self.app_id)
# Look the users up by their email address and map them back to their
# id here for future queries (if needed). This allows people to
# specify a full email as a recipient via slack

@ -93,6 +93,9 @@ class NotifyTelegram(NotifyBase):
# A URL that takes you to the setup/help of the specific protocol
setup_url = 'https://github.com/caronc/apprise/wiki/Notify_telegram'
# Default Notify Format
notify_format = NotifyFormat.HTML
# Telegram uses the http protocol with JSON requests
notify_url = 'https://api.telegram.org/bot'
@ -102,6 +105,9 @@ class NotifyTelegram(NotifyBase):
# The maximum allowable characters allowed in the body per message
body_maxlen = 4096
# Title is to be part of body
title_maxlen = 0
# Telegram is limited to sending a maximum of 100 requests per second.
request_rate_per_sec = 0.001
@ -167,6 +173,49 @@ class NotifyTelegram(NotifyBase):
},
)
# Telegram's HTML support doesn't like having HTML escaped
# characters passed into it. to handle this situation, we need to
# search the body for these sequences and convert them to the
# output the user expected
__telegram_escape_html_dict = {
# New Lines
re.compile(r'<\s*/?br\s*/?>\r*\n?', re.I): '\r\n',
re.compile(r'<\s*/(br|p|div|li)[^>]*>\r*\n?', re.I): '\r\n',
# The following characters can be altered to become supported
re.compile(r'<\s*pre[^>]*>', re.I): '<code>',
re.compile(r'<\s*/pre[^>]*>', re.I): '</code>',
# the following tags are not supported
re.compile(
r'<\s*(br|p|div|span|body|script|meta|html|font'
r'|label|iframe|li|ol|ul|source|script)[^>]*>', re.I): '',
re.compile(
r'<\s*/(span|body|script|meta|html|font'
r'|label|iframe|ol|ul|source|script)[^>]*>', re.I): '',
# Italic
re.compile(r'<\s*(caption|em)[^>]*>', re.I): '<i>',
re.compile(r'<\s*/(caption|em)[^>]*>', re.I): '</i>',
# Bold
re.compile(r'<\s*(h[1-6]|title|strong)[^>]*>', re.I): '<b>',
re.compile(r'<\s*/(h[1-6]|title|strong)[^>]*>', re.I): '</b>',
# HTML Spaces (&nbsp;) and tabs (&emsp;) aren't supported
# See https://core.telegram.org/bots/api#html-style
re.compile(r'\&nbsp;?', re.I): ' ',
# Tabs become 3 spaces
re.compile(r'\&emsp;?', re.I): ' ',
# Some characters get re-escaped by the Telegram upstream
# service so we need to convert these back,
re.compile(r'\&apos;?', re.I): '\'',
re.compile(r'\&quot;?', re.I): '"',
}
# Define our template tokens
template_tokens = dict(NotifyBase.template_tokens, **{
'bot_token': {
@ -483,15 +532,15 @@ class NotifyTelegram(NotifyBase):
# "text":"/start",
# "entities":[{"offset":0,"length":6,"type":"bot_command"}]}}]
if 'ok' in response and response['ok'] is True \
and 'result' in response and len(response['result']):
entry = response['result'][0]
_id = entry['message']['from'].get('id', 0)
_user = entry['message']['from'].get('first_name')
self.logger.info('Detected Telegram user %s (userid=%d)' % (
_user, _id))
# Return our detected userid
return _id
if response.get('ok', False):
for entry in response.get('result', []):
if 'message' in entry and 'from' in entry['message']:
_id = entry['message']['from'].get('id', 0)
_user = entry['message']['from'].get('first_name')
self.logger.info(
'Detected Telegram user %s (userid=%d)' % (_user, _id))
# Return our detected userid
return _id
self.logger.warning(
'Failed to detect a Telegram user; '
@ -499,7 +548,7 @@ class NotifyTelegram(NotifyBase):
return 0
def send(self, body, title='', notify_type=NotifyType.INFO, attach=None,
**kwargs):
body_format=None, **kwargs):
"""
Perform Telegram Notification
"""
@ -538,87 +587,47 @@ class NotifyTelegram(NotifyBase):
'disable_web_page_preview': not self.preview,
}
# Prepare Email Message
# Prepare Message Body
if self.notify_format == NotifyFormat.MARKDOWN:
payload['parse_mode'] = 'MARKDOWN'
payload['text'] = '{}{}'.format(
'{}\r\n'.format(title) if title else '',
body,
)
payload['text'] = body
else: # HTML or TEXT
else: # HTML
# Use Telegram's HTML mode
payload['parse_mode'] = 'HTML'
# Telegram's HTML support doesn't like having HTML escaped
# characters passed into it. to handle this situation, we need to
# search the body for these sequences and convert them to the
# output the user expected
telegram_escape_html_dict = {
# HTML Spaces (&nbsp;) and tabs (&emsp;) aren't supported
# See https://core.telegram.org/bots/api#html-style
r'nbsp': ' ',
# Tabs become 3 spaces
r'emsp': ' ',
# Some characters get re-escaped by the Telegram upstream
# service so we need to convert these back,
r'apos': '\'',
r'quot': '"',
}
# Create a regular expression from the dictionary keys
html_regex = re.compile("&(%s);?" % "|".join(
map(re.escape, telegram_escape_html_dict.keys())).lower(),
re.I)
# For each match, look-up corresponding value in dictionary
# we look +1 to ignore the & that does not appear in the index
# we only look at the first 4 characters because we don't want to
# fail on &apos; as it's accepted (along with &apos - no
# semi-colon)
body = html_regex.sub( # pragma: no branch
lambda mo: telegram_escape_html_dict[
mo.string[mo.start():mo.end()][1:5]], body)
if title:
# For each match, look-up corresponding value in dictionary
# Indexing is explained above (for how the body is parsed)
title = html_regex.sub( # pragma: no branch
lambda mo: telegram_escape_html_dict[
mo.string[mo.start():mo.end()][1:5]], title)
if self.notify_format == NotifyFormat.TEXT:
telegram_escape_text_dict = {
# We need to escape characters that conflict with html
# entity blocks (< and >) when displaying text
r'>': '&gt;',
r'<': '&lt;',
}
# Create a regular expression from the dictionary keys
text_regex = re.compile("(%s)" % "|".join(
map(re.escape, telegram_escape_text_dict.keys())).lower(),
re.I)
# For each match, look-up corresponding value in dictionary
body = text_regex.sub( # pragma: no branch
lambda mo: telegram_escape_text_dict[
mo.string[mo.start():mo.end()]], body)
if title:
# For each match, look-up corresponding value in dictionary
title = text_regex.sub( # pragma: no branch
lambda mo: telegram_escape_text_dict[
mo.string[mo.start():mo.end()]], title)
payload['text'] = '{}{}'.format(
'<b>{}</b>\r\n'.format(title) if title else '',
body,
)
for r, v in self.__telegram_escape_html_dict.items():
body = r.sub(v, body, re.I)
# Prepare our payload based on HTML or TEXT
payload['text'] = body
# else: # self.notify_format == NotifyFormat.TEXT:
# # Use Telegram's HTML mode
# payload['parse_mode'] = 'HTML'
# # Further html escaping required...
# telegram_escape_text_dict = {
# # We need to escape characters that conflict with html
# # entity blocks (< and >) when displaying text
# r'>': '&gt;',
# r'<': '&lt;',
# r'\&': '&amp;',
# }
# # Create a regular expression from the dictionary keys
# text_regex = re.compile("(%s)" % "|".join(
# map(re.escape, telegram_escape_text_dict.keys())).lower(),
# re.I)
# # For each match, look-up corresponding value in dictionary
# body = text_regex.sub( # pragma: no branch
# lambda mo: telegram_escape_text_dict[
# mo.string[mo.start():mo.end()]], body)
# # prepare our payload based on HTML or TEXT
# payload['text'] = body
# Create a copy of the chat_ids list
targets = list(self.targets)

@ -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 = \
@ -532,15 +744,6 @@ class NotifyTwitter(NotifyBase):
# Mark our failure
return (False, content)
try:
content = loads(r.content)
except (AttributeError, TypeError, ValueError):
# ValueError = r.content is Unparsable
# TypeError = r.content is None
# AttributeError = r is None
content = {}
try:
# Capture rate limiting if possible
self.ratelimit_remaining = \
@ -562,6 +765,20 @@ class NotifyTwitter(NotifyBase):
# Mark our failure
return (False, content)
except (OSError, IOError) as e:
self.logger.warning(
'An I/O error occurred while handling {}.'.format(
payload.name if isinstance(payload, AttachBase)
else payload))
self.logger.debug('I/O Exception: %s' % str(e))
return (False, content)
finally:
# Close our file (if it's open) stored in the second element
# of our files tuple (index 1)
if files:
files['media'][1].close()
return (True, content)
@property
@ -581,6 +798,8 @@ class NotifyTwitter(NotifyBase):
# Define any URL parameters
params = {
'mode': self.mode,
'batch': 'yes' if self.batch else 'no',
'cache': 'yes' if self.cache else 'no',
}
# Extend our parameters
@ -653,10 +872,16 @@ class NotifyTwitter(NotifyBase):
# Store any remaining items as potential targets
results['targets'].extend(tokens[3:])
# Get Cache Flag (reduces lookup hits)
if 'cache' in results['qsd'] and len(results['qsd']['cache']):
results['cache'] = \
parse_bool(results['qsd']['cache'], True)
# Get Batch Mode Flag
results['batch'] = \
parse_bool(results['qsd'].get(
'batch', NotifyTwitter.template_args['batch']['default']))
# The 'to' makes it easier to use yaml configuration
if 'to' in results['qsd'] and len(results['qsd']['to']):
results['targets'] += \

@ -35,6 +35,16 @@ from ..common import NotifyType
from ..AppriseLocale import gettext_lazy as _
# Defines the method to send the notification
METHODS = (
'POST',
'GET',
'DELETE',
'PUT',
'HEAD'
)
class NotifyXML(NotifyBase):
"""
A wrapper for XML Notifications
@ -98,6 +108,17 @@ class NotifyXML(NotifyBase):
'type': 'string',
'private': True,
},
})
# Define our template arguments
template_args = dict(NotifyBase.template_args, **{
'method': {
'name': _('Fetch Method'),
'type': 'choice:string',
'values': METHODS,
'default': METHODS[0],
},
})
# Define any kwargs we're using
@ -106,9 +127,13 @@ class NotifyXML(NotifyBase):
'name': _('HTTP Header'),
'prefix': '+',
},
'payload': {
'name': _('Payload Extras'),
'prefix': ':',
},
}
def __init__(self, headers=None, **kwargs):
def __init__(self, headers=None, method=None, payload=None, **kwargs):
"""
Initialize XML Object
@ -124,25 +149,43 @@ class NotifyXML(NotifyBase):
xmlns:xsd="http://www.w3.org/2001/XMLSchema"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
<soapenv:Body>
<Notification xmlns:xsi="{XSD_URL}">
<Version>{XSD_VER}</Version>
<Subject>{SUBJECT}</Subject>
<MessageType>{MESSAGE_TYPE}</MessageType>
<Message>{MESSAGE}</Message>
{ATTACHMENTS}
<Notification xmlns:xsi="{{XSD_URL}}">
{{CORE}}
{{ATTACHMENTS}}
</Notification>
</soapenv:Body>
</soapenv:Envelope>"""
self.fullpath = kwargs.get('fullpath')
if not isinstance(self.fullpath, six.string_types):
self.fullpath = '/'
self.fullpath = ''
self.method = self.template_args['method']['default'] \
if not isinstance(method, six.string_types) else method.upper()
if self.method not in METHODS:
msg = 'The method specified ({}) is invalid.'.format(method)
self.logger.warning(msg)
raise TypeError(msg)
self.headers = {}
if headers:
# Store our extra headers
self.headers.update(headers)
self.payload_extras = {}
if payload:
# Store our extra payload entries (but tidy them up since they will
# become XML Keys (they can't contain certain characters
for k, v in payload.items():
key = re.sub(r'[^A-Za-z0-9_-]*', '', k)
if not key:
self.logger.warning(
'Ignoring invalid XML Stanza element name({})'
.format(k))
continue
self.payload_extras[key] = v
return
def url(self, privacy=False, *args, **kwargs):
@ -150,12 +193,21 @@ class NotifyXML(NotifyBase):
Returns the URL built dynamically based on specified arguments.
"""
# Store our defined headers into our URL parameters
params = {'+{}'.format(k): v for k, v in self.headers.items()}
# Define any URL parameters
params = {
'method': self.method,
}
# Extend our parameters
params.update(self.url_parameters(privacy=privacy, *args, **kwargs))
# Append our headers into our parameters
params.update({'+{}'.format(k): v for k, v in self.headers.items()})
# Append our payload extra's into our parameters
params.update(
{':{}'.format(k): v for k, v in self.payload_extras.items()})
# Determine Authentication
auth = ''
if self.user and self.password:
@ -171,14 +223,15 @@ class NotifyXML(NotifyBase):
default_port = 443 if self.secure else 80
return '{schema}://{auth}{hostname}{port}{fullpath}/?{params}'.format(
return '{schema}://{auth}{hostname}{port}{fullpath}?{params}'.format(
schema=self.secure_protocol if self.secure else self.protocol,
auth=auth,
# never encode hostname since we're expecting it to be a valid one
hostname=self.host,
port='' if self.port is None or self.port == default_port
else ':{}'.format(self.port),
fullpath=NotifyXML.quote(self.fullpath, safe='/'),
fullpath=NotifyXML.quote(self.fullpath, safe='/')
if self.fullpath else '/',
params=NotifyXML.urlencode(params),
)
@ -200,7 +253,24 @@ class NotifyXML(NotifyBase):
# Our XML Attachmement subsitution
xml_attachments = ''
# Track our potential attachments
# Our Payload Base
payload_base = {
'Version': self.xsd_ver,
'Subject': NotifyXML.escape_html(title, whitespace=False),
'MessageType': NotifyXML.escape_html(
notify_type, whitespace=False),
'Message': NotifyXML.escape_html(body, whitespace=False),
}
# Apply our payload extras
payload_base.update(
{k: NotifyXML.escape_html(v, whitespace=False)
for k, v in self.payload_extras.items()})
# Base Entres
xml_base = ''.join(
['<{}>{}</{}>'.format(k, v, k) for k, v in payload_base.items()])
attachments = []
if attach:
for attachment in attach:
@ -239,13 +309,9 @@ class NotifyXML(NotifyBase):
''.join(attachments) + '</Attachments>'
re_map = {
'{XSD_VER}': self.xsd_ver,
'{XSD_URL}': self.xsd_url.format(version=self.xsd_ver),
'{MESSAGE_TYPE}': NotifyXML.escape_html(
notify_type, whitespace=False),
'{SUBJECT}': NotifyXML.escape_html(title, whitespace=False),
'{MESSAGE}': NotifyXML.escape_html(body, whitespace=False),
'{ATTACHMENTS}': xml_attachments,
'{{XSD_URL}}': self.xsd_url.format(version=self.xsd_ver),
'{{ATTACHMENTS}}': xml_attachments,
'{{CORE}}': xml_base,
}
# Iterate over above list and store content accordingly
@ -277,8 +343,23 @@ class NotifyXML(NotifyBase):
# Always call throttle before any remote server i/o is made
self.throttle()
if self.method == 'GET':
method = requests.get
elif self.method == 'PUT':
method = requests.put
elif self.method == 'DELETE':
method = requests.delete
elif self.method == 'HEAD':
method = requests.head
else: # POST
method = requests.post
try:
r = requests.post(
r = method(
url,
data=payload,
headers=headers,
@ -286,17 +367,17 @@ class NotifyXML(NotifyBase):
verify=self.verify_certificate,
timeout=self.request_timeout,
)
if r.status_code != requests.codes.ok:
if r.status_code < 200 or r.status_code >= 300:
# We had a problem
status_str = \
NotifyXML.http_response_code_lookup(r.status_code)
self.logger.warning(
'Failed to send XML notification: '
'{}{}error={}.'.format(
status_str,
', ' if status_str else '',
r.status_code))
'Failed to send JSON %s notification: %s%serror=%s.',
self.method,
status_str,
', ' if status_str else '',
str(r.status_code))
self.logger.debug('Response Details:\r\n{}'.format(r.content))
@ -304,7 +385,7 @@ class NotifyXML(NotifyBase):
return False
else:
self.logger.info('Sent XML notification.')
self.logger.info('Sent XML %s notification.', self.method)
except requests.RequestException as e:
self.logger.warning(
@ -329,6 +410,10 @@ class NotifyXML(NotifyBase):
# We're done early as we couldn't load the results
return results
# store any additional payload extra's defined
results['payload'] = {NotifyXML.unquote(x): NotifyXML.unquote(y)
for x, y in results['qsd:'].items()}
# Add our headers that the user can potentially over-ride if they wish
# to to our returned result set
results['headers'] = results['qsd+']
@ -342,4 +427,8 @@ class NotifyXML(NotifyBase):
results['headers'] = {NotifyXML.unquote(x): NotifyXML.unquote(y)
for x, y in results['headers'].items()}
# Set method if not otherwise set
if 'method' in results['qsd'] and len(results['qsd']['method']):
results['method'] = NotifyXML.unquote(results['qsd']['method'])
return results

@ -28,8 +28,11 @@ import six
import json
import contextlib
import os
from itertools import chain
from os.path import expanduser
from functools import reduce
from .common import MATCH_ALL_TAG
from .common import MATCH_ALWAYS_TAG
try:
# Python 2.7
@ -133,6 +136,17 @@ IS_PHONE_NO = re.compile(r'^\+?(?P<phone>[0-9\s)(+-]+)\s*$')
PHONE_NO_DETECTION_RE = re.compile(
r'\s*([+(\s]*[0-9][0-9()\s-]+[0-9])(?=$|[\s,+(]+[0-9])', re.I)
# A simple verification check to make sure the content specified
# rougly conforms to a ham radio call sign before we parse it further
IS_CALL_SIGN = re.compile(
r'^(?P<callsign>[a-z0-9]{2,3}[0-9][a-z0-9]{3})'
r'(?P<ssid>-[a-z0-9]{1,2})?\s*$', re.I)
# Regular expression used to destinguish between multiple ham radio call signs
CALL_SIGN_DETECTION_RE = re.compile(
r'\s*([a-z0-9]{2,3}[0-9][a-z0-9]{3}(?:-[a-z0-9]{1,2})?)'
r'(?=$|[\s,]+[a-z0-9]{4,6})', re.I)
# Regular expression used to destinguish between multiple URLs
URL_DETECTION_RE = re.compile(
r'([a-z0-9]+?:\/\/.*?)(?=$|[\s,]+[a-z0-9]{2,9}?:\/\/)', re.I)
@ -372,6 +386,37 @@ def is_phone_no(phone, min_len=11):
}
def is_call_sign(callsign):
"""Determine if the specified entry is a ham radio call sign
Args:
callsign (str): The string you want to check.
Returns:
bool: Returns False if the address specified is not a phone number
"""
try:
result = IS_CALL_SIGN.match(callsign)
if not result:
# not parseable content as it does not even conform closely to a
# callsign
return False
except TypeError:
# not parseable content
return False
ssid = result.group('ssid')
return {
# always treat call signs as uppercase content
'callsign': result.group('callsign').upper(),
# Prevent the storing of the None keyword in the event the SSID was
# not detected
'ssid': ssid if ssid else '',
}
def is_email(address):
"""Determine if the specified entry is an email address
@ -523,7 +568,7 @@ def parse_qsd(qs):
return result
def parse_url(url, default_schema='http', verify_host=True):
def parse_url(url, default_schema='http', verify_host=True, strict_port=False):
"""A function that greatly simplifies the parsing of a url
specified by the end user.
@ -655,13 +700,29 @@ def parse_url(url, default_schema='http', verify_host=True):
# and it's already assigned
pass
# Max port is 65535 so (1,5 digits)
match = re.search(
r'^(?P<host>.+):(?P<port>[1-9][0-9]{0,4})$', result['host'])
if match:
# Port Parsing
pmatch = re.search(
r'^(?P<host>(\[[0-9a-f:]+\]|[^:]+)):(?P<port>[^:]*)$',
result['host'])
if pmatch:
# Separate our port from our hostname (if port is detected)
result['host'] = match.group('host')
result['port'] = int(match.group('port'))
result['host'] = pmatch.group('host')
try:
# If we're dealing with an integer, go ahead and convert it
# otherwise return an 'x' which will raise a ValueError
#
# This small extra check allows us to treat floats/doubles
# as strings. Hence a value like '4.2' won't be converted to a 4
# (and the .2 lost)
result['port'] = int(
pmatch.group('port')
if re.search(r'[0-9]', pmatch.group('port')) else 'x')
except ValueError:
if verify_host:
# Invalid Host Specified
return None
if verify_host:
# Verify and Validate our hostname
@ -671,6 +732,26 @@ def parse_url(url, default_schema='http', verify_host=True):
# some indication as to what went wrong
return None
# Max port is 65535 and min is 1
if isinstance(result['port'], int) and not ((
not strict_port or (
strict_port and
result['port'] > 0 and result['port'] <= 65535))):
# An invalid port was specified
return None
elif pmatch and not isinstance(result['port'], int):
if strict_port:
# Store port
result['port'] = pmatch.group('port').strip()
else:
# Fall back
result['port'] = None
result['host'] = '{}:{}'.format(
pmatch.group('host'), pmatch.group('port'))
# Re-assemble cleaned up version of the url
result['url'] = '%s://' % result['schema']
if isinstance(result['user'], six.string_types):
@ -683,8 +764,12 @@ def parse_url(url, default_schema='http', verify_host=True):
result['url'] += '@'
result['url'] += result['host']
if result['port']:
result['url'] += ':%d' % result['port']
if result['port'] is not None:
try:
result['url'] += ':%d' % result['port']
except TypeError:
result['url'] += ':%s' % result['port']
if result['fullpath']:
result['url'] += result['fullpath']
@ -766,6 +851,43 @@ def parse_phone_no(*args, **kwargs):
return result
def parse_call_sign(*args, **kwargs):
"""
Takes a string containing ham radio call signs separated by
comma and/or spacesand returns a list.
"""
# for Python 2.7 support, store_unparsable is not in the url above
# as just parse_emails(*args, store_unparseable=True) since it is
# an invalid syntax. This is the workaround to be backards compatible:
store_unparseable = kwargs.get('store_unparseable', True)
result = []
for arg in args:
if isinstance(arg, six.string_types) and arg:
_result = CALL_SIGN_DETECTION_RE.findall(arg)
if _result:
result += _result
elif not _result and store_unparseable:
# we had content passed into us that was lost because it was
# so poorly formatted that it didn't even come close to
# meeting the regular expression we defined. We intentially
# keep it as part of our result set so that parsing done
# at a higher level can at least report this to the end user
# and hopefully give them some indication as to what they
# may have done wrong.
result += \
[x for x in filter(bool, re.split(STRING_DELIMITERS, arg))]
elif isinstance(arg, (set, list, tuple)):
# Use recursion to handle the list of call signs
result += parse_call_sign(
*arg, store_unparseable=store_unparseable)
return result
def parse_emails(*args, **kwargs):
"""
Takes a string containing emails separated by comma's and/or spaces and
@ -876,7 +998,8 @@ def parse_list(*args):
return sorted([x for x in filter(bool, list(set(result)))])
def is_exclusive_match(logic, data, match_all='all'):
def is_exclusive_match(logic, data, match_all=MATCH_ALL_TAG,
match_always=MATCH_ALWAYS_TAG):
"""
The data variable should always be a set of strings that the logic can be
@ -892,6 +1015,9 @@ def is_exclusive_match(logic, data, match_all='all'):
logic=['tagA', 'tagB'] = tagA or tagB
logic=[('tagA', 'tagC'), 'tagB'] = (tagA and tagC) or tagB
logic=[('tagB', 'tagC')] = tagB and tagC
If `match_always` is not set to None, then its value is added as an 'or'
to all specified logic searches.
"""
if isinstance(logic, six.string_types):
@ -907,6 +1033,10 @@ def is_exclusive_match(logic, data, match_all='all'):
# garbage input
return False
if match_always:
# Add our match_always to our logic searching if secified
logic = chain(logic, [match_always])
# Track what we match against; but by default we do not match
# against anything
matched = False

@ -1,6 +1,6 @@
# Bazarr dependencies
argparse==1.4.0
apprise==0.9.6
apprise==0.9.8.3
apscheduler==3.8.1
charamel==1.0.0
deep-translator==1.8.3

Loading…
Cancel
Save