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