8 changed files with 593 additions and 16 deletions
@ -0,0 +1,208 @@ |
|||||
|
# -*- coding: utf-8 -*- |
||||
|
|
||||
|
import ssl |
||||
|
from os.path import isfile |
||||
|
import logging |
||||
|
|
||||
|
|
||||
|
# Default our global support flag |
||||
|
SLEEKXMPP_SUPPORT_AVAILABLE = False |
||||
|
|
||||
|
try: |
||||
|
# Import sleekxmpp if available |
||||
|
import sleekxmpp |
||||
|
|
||||
|
SLEEKXMPP_SUPPORT_AVAILABLE = True |
||||
|
|
||||
|
except ImportError: |
||||
|
# No problem; we just simply can't support this plugin because we're |
||||
|
# either using Linux, or simply do not have sleekxmpp installed. |
||||
|
pass |
||||
|
|
||||
|
|
||||
|
class SleekXmppAdapter(object): |
||||
|
""" |
||||
|
Wrapper to sleekxmpp |
||||
|
|
||||
|
""" |
||||
|
|
||||
|
# Reference to XMPP client. |
||||
|
xmpp = None |
||||
|
|
||||
|
# Whether everything succeeded |
||||
|
success = False |
||||
|
|
||||
|
# The default protocol |
||||
|
protocol = 'xmpp' |
||||
|
|
||||
|
# The default secure protocol |
||||
|
secure_protocol = 'xmpps' |
||||
|
|
||||
|
# The default XMPP port |
||||
|
default_unsecure_port = 5222 |
||||
|
|
||||
|
# The default XMPP secure port |
||||
|
default_secure_port = 5223 |
||||
|
|
||||
|
# Taken from https://golang.org/src/crypto/x509/root_linux.go |
||||
|
CA_CERTIFICATE_FILE_LOCATIONS = [ |
||||
|
# Debian/Ubuntu/Gentoo etc. |
||||
|
"/etc/ssl/certs/ca-certificates.crt", |
||||
|
# Fedora/RHEL 6 |
||||
|
"/etc/pki/tls/certs/ca-bundle.crt", |
||||
|
# OpenSUSE |
||||
|
"/etc/ssl/ca-bundle.pem", |
||||
|
# OpenELEC |
||||
|
"/etc/pki/tls/cacert.pem", |
||||
|
# CentOS/RHEL 7 |
||||
|
"/etc/pki/ca-trust/extracted/pem/tls-ca-bundle.pem", |
||||
|
] |
||||
|
|
||||
|
# This entry is a bit hacky, but it allows us to unit-test this library |
||||
|
# in an environment that simply doesn't have the sleekxmpp package |
||||
|
# available to us. |
||||
|
# |
||||
|
# If anyone is seeing this had knows a better way of testing this |
||||
|
# outside of what is defined in test/test_xmpp_plugin.py, please |
||||
|
# let me know! :) |
||||
|
_enabled = SLEEKXMPP_SUPPORT_AVAILABLE |
||||
|
|
||||
|
def __init__(self, host=None, port=None, secure=False, |
||||
|
verify_certificate=True, xep=None, jid=None, password=None, |
||||
|
body=None, targets=None, before_message=None, logger=None): |
||||
|
""" |
||||
|
Initialize our SleekXmppAdapter object |
||||
|
""" |
||||
|
|
||||
|
self.host = host |
||||
|
self.port = port |
||||
|
self.secure = secure |
||||
|
self.verify_certificate = verify_certificate |
||||
|
|
||||
|
self.xep = xep |
||||
|
self.jid = jid |
||||
|
self.password = password |
||||
|
|
||||
|
self.body = body |
||||
|
self.targets = targets |
||||
|
self.before_message = before_message |
||||
|
|
||||
|
self.logger = logger or logging.getLogger(__name__) |
||||
|
|
||||
|
# Use the Apprise log handlers for configuring the sleekxmpp logger. |
||||
|
apprise_logger = logging.getLogger('apprise') |
||||
|
sleek_logger = logging.getLogger('sleekxmpp') |
||||
|
for handler in apprise_logger.handlers: |
||||
|
sleek_logger.addHandler(handler) |
||||
|
sleek_logger.setLevel(apprise_logger.level) |
||||
|
|
||||
|
if not self.load(): |
||||
|
raise ValueError("Invalid XMPP Configuration") |
||||
|
|
||||
|
def load(self): |
||||
|
|
||||
|
# Prepare our object |
||||
|
self.xmpp = sleekxmpp.ClientXMPP(self.jid, self.password) |
||||
|
|
||||
|
# Register our session |
||||
|
self.xmpp.add_event_handler("session_start", self.session_start) |
||||
|
|
||||
|
for xep in self.xep: |
||||
|
# Load xep entries |
||||
|
try: |
||||
|
self.xmpp.register_plugin('xep_{0:04d}'.format(xep)) |
||||
|
|
||||
|
except sleekxmpp.plugins.base.PluginNotFound: |
||||
|
self.logger.warning( |
||||
|
'Could not register plugin {}'.format( |
||||
|
'xep_{0:04d}'.format(xep))) |
||||
|
return False |
||||
|
|
||||
|
if self.secure: |
||||
|
# Don't even try to use the outdated ssl.PROTOCOL_SSLx |
||||
|
self.xmpp.ssl_version = ssl.PROTOCOL_TLSv1 |
||||
|
|
||||
|
# If the python version supports it, use highest TLS version |
||||
|
# automatically |
||||
|
if hasattr(ssl, "PROTOCOL_TLS"): |
||||
|
# Use the best version of TLS available to us |
||||
|
self.xmpp.ssl_version = ssl.PROTOCOL_TLS |
||||
|
|
||||
|
self.xmpp.ca_certs = None |
||||
|
if self.verify_certificate: |
||||
|
# Set the ca_certs variable for certificate verification |
||||
|
self.xmpp.ca_certs = next( |
||||
|
(cert for cert in self.CA_CERTIFICATE_FILE_LOCATIONS |
||||
|
if isfile(cert)), None) |
||||
|
|
||||
|
if self.xmpp.ca_certs is None: |
||||
|
self.logger.warning( |
||||
|
'XMPP Secure comunication can not be verified; ' |
||||
|
'no local CA certificate file') |
||||
|
return False |
||||
|
|
||||
|
# We're good |
||||
|
return True |
||||
|
|
||||
|
def process(self): |
||||
|
""" |
||||
|
Thread that handles the server/client i/o |
||||
|
|
||||
|
""" |
||||
|
|
||||
|
# Establish connection to XMPP server. |
||||
|
# To speed up sending messages, don't use the "reattempt" feature, |
||||
|
# it will add a nasty delay even before connecting to XMPP server. |
||||
|
if not self.xmpp.connect((self.host, self.port), |
||||
|
use_ssl=self.secure, reattempt=False): |
||||
|
|
||||
|
default_port = self.default_secure_port \ |
||||
|
if self.secure else self.default_unsecure_port |
||||
|
|
||||
|
default_schema = self.secure_protocol \ |
||||
|
if self.secure else self.protocol |
||||
|
|
||||
|
# Log connection issue |
||||
|
self.logger.warning( |
||||
|
'Failed to authenticate {jid} with: {schema}://{host}{port}' |
||||
|
.format( |
||||
|
jid=self.jid, |
||||
|
schema=default_schema, |
||||
|
host=self.host, |
||||
|
port='' if not self.port or self.port == default_port |
||||
|
else ':{}'.format(self.port), |
||||
|
)) |
||||
|
return False |
||||
|
|
||||
|
# Process XMPP communication. |
||||
|
self.xmpp.process(block=True) |
||||
|
|
||||
|
return self.success |
||||
|
|
||||
|
def session_start(self, *args, **kwargs): |
||||
|
""" |
||||
|
Session Manager |
||||
|
""" |
||||
|
|
||||
|
targets = list(self.targets) |
||||
|
if not targets: |
||||
|
# We always default to notifying ourselves |
||||
|
targets.append(self.jid) |
||||
|
|
||||
|
while len(targets) > 0: |
||||
|
|
||||
|
# Get next target (via JID) |
||||
|
target = targets.pop(0) |
||||
|
|
||||
|
# Invoke "before_message" event hook. |
||||
|
self.before_message() |
||||
|
|
||||
|
# The message we wish to send, and the JID that will receive it. |
||||
|
self.xmpp.send_message(mto=target, mbody=self.body, mtype='chat') |
||||
|
|
||||
|
# Using wait=True ensures that the send queue will be |
||||
|
# emptied before ending the session. |
||||
|
self.xmpp.disconnect(wait=True) |
||||
|
|
||||
|
# Toggle our success flag |
||||
|
self.success = True |
@ -0,0 +1,351 @@ |
|||||
|
# -*- 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. |
||||
|
|
||||
|
import re |
||||
|
|
||||
|
from ..NotifyBase import NotifyBase |
||||
|
from ...URLBase import PrivacyMode |
||||
|
from ...common import NotifyType |
||||
|
from ...utils import parse_list |
||||
|
from ...AppriseLocale import gettext_lazy as _ |
||||
|
from .SleekXmppAdapter import SleekXmppAdapter |
||||
|
|
||||
|
# xep string parser |
||||
|
XEP_PARSE_RE = re.compile('^[^1-9]*(?P<xep>[1-9][0-9]{0,3})$') |
||||
|
|
||||
|
|
||||
|
class NotifyXMPP(NotifyBase): |
||||
|
""" |
||||
|
A wrapper for XMPP Notifications |
||||
|
""" |
||||
|
|
||||
|
# The default descriptive name associated with the Notification |
||||
|
service_name = 'XMPP' |
||||
|
|
||||
|
# The default protocol |
||||
|
protocol = 'xmpp' |
||||
|
|
||||
|
# The default secure protocol |
||||
|
secure_protocol = 'xmpps' |
||||
|
|
||||
|
# A URL that takes you to the setup/help of the specific protocol |
||||
|
setup_url = 'https://github.com/caronc/apprise/wiki/Notify_xmpp' |
||||
|
|
||||
|
# Lower throttle rate for XMPP |
||||
|
request_rate_per_sec = 0.5 |
||||
|
|
||||
|
# The default XMPP port |
||||
|
default_unsecure_port = 5222 |
||||
|
|
||||
|
# The default XMPP secure port |
||||
|
default_secure_port = 5223 |
||||
|
|
||||
|
# XMPP does not support a title |
||||
|
title_maxlen = 0 |
||||
|
|
||||
|
# This entry is a bit hacky, but it allows us to unit-test this library |
||||
|
# in an environment that simply doesn't have the sleekxmpp package |
||||
|
# available to us. |
||||
|
# |
||||
|
# If anyone is seeing this had knows a better way of testing this |
||||
|
# outside of what is defined in test/test_xmpp_plugin.py, please |
||||
|
# let me know! :) |
||||
|
_enabled = SleekXmppAdapter._enabled |
||||
|
|
||||
|
# Define object templates |
||||
|
templates = ( |
||||
|
'{schema}://{host}', |
||||
|
'{schema}://{password}@{host}', |
||||
|
'{schema}://{password}@{host}:{port}', |
||||
|
'{schema}://{user}:{password}@{host}', |
||||
|
'{schema}://{user}:{password}@{host}:{port}', |
||||
|
'{schema}://{host}/{targets}', |
||||
|
'{schema}://{password}@{host}/{targets}', |
||||
|
'{schema}://{password}@{host}:{port}/{targets}', |
||||
|
'{schema}://{user}:{password}@{host}/{targets}', |
||||
|
'{schema}://{user}:{password}@{host}:{port}/{targets}', |
||||
|
) |
||||
|
|
||||
|
# Define our 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, |
||||
|
'required': True, |
||||
|
}, |
||||
|
'target_jid': { |
||||
|
'name': _('Target JID'), |
||||
|
'type': 'string', |
||||
|
'map_to': 'targets', |
||||
|
}, |
||||
|
'targets': { |
||||
|
'name': _('Targets'), |
||||
|
'type': 'list:string', |
||||
|
}, |
||||
|
}) |
||||
|
|
||||
|
# Define our template arguments |
||||
|
template_args = dict(NotifyBase.template_args, **{ |
||||
|
'to': { |
||||
|
'alias_of': 'targets', |
||||
|
}, |
||||
|
'xep': { |
||||
|
'name': _('XEP'), |
||||
|
'type': 'list:string', |
||||
|
'prefix': 'xep-', |
||||
|
'regex': (r'^[1-9][0-9]{0,3}$', 'i'), |
||||
|
}, |
||||
|
'jid': { |
||||
|
'name': _('Source JID'), |
||||
|
'type': 'string', |
||||
|
}, |
||||
|
}) |
||||
|
|
||||
|
def __init__(self, targets=None, jid=None, xep=None, **kwargs): |
||||
|
""" |
||||
|
Initialize XMPP Object |
||||
|
""" |
||||
|
super(NotifyXMPP, self).__init__(**kwargs) |
||||
|
|
||||
|
# JID Details: |
||||
|
# - JID's normally have an @ symbol in them, but it is not required |
||||
|
# - Each allowable portion of a JID MUST NOT be more than 1023 bytes |
||||
|
# in length. |
||||
|
# - JID's can identify resource paths at the end separated by slashes |
||||
|
# hence the following is valid: user@example.com/resource/path |
||||
|
|
||||
|
# Since JID's can clash with URLs offered by aprise (specifically the |
||||
|
# resource paths we need to allow users an alternative character to |
||||
|
# represent the slashes. The grammer is defined here: |
||||
|
# https://xmpp.org/extensions/xep-0029.html as follows: |
||||
|
# |
||||
|
# <JID> ::= [<node>"@"]<domain>["/"<resource>] |
||||
|
# <node> ::= <conforming-char>[<conforming-char>]* |
||||
|
# <domain> ::= <hname>["."<hname>]* |
||||
|
# <resource> ::= <any-char>[<any-char>]* |
||||
|
# <hname> ::= <let>|<dig>[[<let>|<dig>|"-"]*<let>|<dig>] |
||||
|
# <let> ::= [a-z] | [A-Z] |
||||
|
# <dig> ::= [0-9] |
||||
|
# <conforming-char> ::= #x21 | [#x23-#x25] | [#x28-#x2E] | |
||||
|
# [#x30-#x39] | #x3B | #x3D | #x3F | |
||||
|
# [#x41-#x7E] | [#x80-#xD7FF] | |
||||
|
# [#xE000-#xFFFD] | [#x10000-#x10FFFF] |
||||
|
# <any-char> ::= [#x20-#xD7FF] | [#xE000-#xFFFD] | |
||||
|
# [#x10000-#x10FFFF] |
||||
|
|
||||
|
# The best way to do this is to choose characters that aren't allowed |
||||
|
# in this case we will use comma and/or space. |
||||
|
|
||||
|
# Assemble our jid using the information available to us: |
||||
|
self.jid = jid |
||||
|
|
||||
|
if not (self.user or self.password): |
||||
|
# you must provide a jid/pass for this to work; if no password |
||||
|
# is specified then the user field acts as the password instead |
||||
|
# so we know that if there is no user specified, our url was |
||||
|
# really busted up. |
||||
|
msg = 'You must specify a XMPP password' |
||||
|
self.logger.warning(msg) |
||||
|
raise TypeError(msg) |
||||
|
|
||||
|
# See https://xmpp.org/extensions/ for details on xep values |
||||
|
if xep is None: |
||||
|
# Default xep setting |
||||
|
self.xep = [ |
||||
|
# xep_0030: Service Discovery |
||||
|
30, |
||||
|
# xep_0199: XMPP Ping |
||||
|
199, |
||||
|
] |
||||
|
|
||||
|
else: |
||||
|
# Prepare the list |
||||
|
_xep = parse_list(xep) |
||||
|
self.xep = [] |
||||
|
|
||||
|
for xep in _xep: |
||||
|
result = XEP_PARSE_RE.match(xep) |
||||
|
if result is not None: |
||||
|
self.xep.append(int(result.group('xep'))) |
||||
|
self.logger.debug('Loaded XMPP {}'.format(xep)) |
||||
|
|
||||
|
else: |
||||
|
self.logger.warning( |
||||
|
"Could not load XMPP {}".format(xep)) |
||||
|
|
||||
|
# By default we send ourselves a message |
||||
|
if targets: |
||||
|
self.targets = parse_list(targets) |
||||
|
|
||||
|
else: |
||||
|
self.targets = list() |
||||
|
|
||||
|
def send(self, body, title='', notify_type=NotifyType.INFO, **kwargs): |
||||
|
""" |
||||
|
Perform XMPP Notification |
||||
|
""" |
||||
|
|
||||
|
if not self._enabled: |
||||
|
self.logger.warning( |
||||
|
'XMPP Notifications are not supported by this system ' |
||||
|
'- install sleekxmpp.') |
||||
|
return False |
||||
|
|
||||
|
# Detect our JID if it isn't otherwise specified |
||||
|
jid = self.jid |
||||
|
password = self.password |
||||
|
if not jid: |
||||
|
if self.user and self.password: |
||||
|
# xmpp://user:password@hostname |
||||
|
jid = '{}@{}'.format(self.user, self.host) |
||||
|
|
||||
|
else: |
||||
|
# xmpp://password@hostname |
||||
|
jid = self.host |
||||
|
password = self.password if self.password else self.user |
||||
|
|
||||
|
# Compute port number |
||||
|
if not self.port: |
||||
|
port = self.default_secure_port \ |
||||
|
if self.secure else self.default_unsecure_port |
||||
|
|
||||
|
else: |
||||
|
port = self.port |
||||
|
|
||||
|
try: |
||||
|
# Communicate with XMPP. |
||||
|
xmpp_adapter = SleekXmppAdapter( |
||||
|
host=self.host, port=port, secure=self.secure, |
||||
|
verify_certificate=self.verify_certificate, xep=self.xep, |
||||
|
jid=jid, password=password, body=body, targets=self.targets, |
||||
|
before_message=self.throttle, logger=self.logger) |
||||
|
|
||||
|
except ValueError: |
||||
|
# We failed |
||||
|
return False |
||||
|
|
||||
|
# Initialize XMPP machinery and begin processing the XML stream. |
||||
|
outcome = xmpp_adapter.process() |
||||
|
|
||||
|
return outcome |
||||
|
|
||||
|
def url(self, privacy=False, *args, **kwargs): |
||||
|
""" |
||||
|
Returns the URL built dynamically based on specified arguments. |
||||
|
""" |
||||
|
|
||||
|
# Define any arguments set |
||||
|
args = { |
||||
|
'format': self.notify_format, |
||||
|
'overflow': self.overflow_mode, |
||||
|
'verify': 'yes' if self.verify_certificate else 'no', |
||||
|
} |
||||
|
|
||||
|
if self.jid: |
||||
|
args['jid'] = self.jid |
||||
|
|
||||
|
if self.xep: |
||||
|
# xep are integers, so we need to just iterate over a list and |
||||
|
# switch them to a string |
||||
|
args['xep'] = ','.join([str(xep) for xep in self.xep]) |
||||
|
|
||||
|
# Target JID(s) can clash with our existing paths, so we just use comma |
||||
|
# and/or space as a delimiters - %20 = space |
||||
|
jids = '%20'.join([NotifyXMPP.quote(x, safe='') for x in self.targets]) |
||||
|
|
||||
|
default_port = self.default_secure_port \ |
||||
|
if self.secure else self.default_unsecure_port |
||||
|
|
||||
|
default_schema = self.secure_protocol if self.secure else self.protocol |
||||
|
|
||||
|
if self.user and self.password: |
||||
|
auth = '{user}:{password}'.format( |
||||
|
user=NotifyXMPP.quote(self.user, safe=''), |
||||
|
password=self.pprint( |
||||
|
self.password, privacy, mode=PrivacyMode.Secret, safe='')) |
||||
|
|
||||
|
else: |
||||
|
auth = self.pprint( |
||||
|
self.password if self.password else self.user, privacy, |
||||
|
mode=PrivacyMode.Secret, safe='') |
||||
|
|
||||
|
return '{schema}://{auth}@{hostname}{port}/{jids}?{args}'.format( |
||||
|
auth=auth, |
||||
|
schema=default_schema, |
||||
|
hostname=NotifyXMPP.quote(self.host, safe=''), |
||||
|
port='' if not self.port or self.port == default_port |
||||
|
else ':{}'.format(self.port), |
||||
|
jids=jids, |
||||
|
args=NotifyXMPP.urlencode(args), |
||||
|
) |
||||
|
|
||||
|
@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) |
||||
|
|
||||
|
if not results: |
||||
|
# We're done early as we couldn't load the results |
||||
|
return results |
||||
|
|
||||
|
# Get our targets; we ignore path slashes since they identify |
||||
|
# our resources |
||||
|
results['targets'] = NotifyXMPP.parse_list(results['fullpath']) |
||||
|
|
||||
|
# Over-ride the xep plugins |
||||
|
if 'xep' in results['qsd'] and len(results['qsd']['xep']): |
||||
|
results['xep'] = \ |
||||
|
NotifyXMPP.parse_list(results['qsd']['xep']) |
||||
|
|
||||
|
# Over-ride the default (and detected) jid |
||||
|
if 'jid' in results['qsd'] and len(results['qsd']['jid']): |
||||
|
results['jid'] = NotifyXMPP.unquote(results['qsd']['jid']) |
||||
|
|
||||
|
# Over-ride the default (and detected) jid |
||||
|
if 'to' in results['qsd'] and len(results['qsd']['to']): |
||||
|
results['targets'] += \ |
||||
|
NotifyXMPP.parse_list(results['qsd']['to']) |
||||
|
|
||||
|
return results |
Loading…
Reference in new issue