From e857600d557a83dc1776d3f11c5dd264ea50a193 Mon Sep 17 00:00:00 2001 From: Wim de With <wf@dewith.io> Date: Wed, 2 Jan 2019 20:25:53 +0100 Subject: [PATCH] Add Matrix notify plugin --- apprise/plugins/NotifyMatrix.py | 266 ++++++++++++++++++++++++++++++++ apprise/plugins/__init__.py | 1 + test/test_rest_plugins.py | 57 +++++++ 3 files changed, 324 insertions(+) create mode 100644 apprise/plugins/NotifyMatrix.py diff --git a/apprise/plugins/NotifyMatrix.py b/apprise/plugins/NotifyMatrix.py new file mode 100644 index 00000000..476af47a --- /dev/null +++ b/apprise/plugins/NotifyMatrix.py @@ -0,0 +1,266 @@ +# -*- coding: utf-8 -*- +# +# Matrix WebHook Notify Wrapper +# +# Copyright (C) 2018 Chris Caron <lead2gold@gmail.com>, Wim de With <wf@dewith.io> +# +# This file is part of apprise. +# +# This program is free software; you can redistribute it and/or modify it +# under the terms of the GNU Lesser General Public License as published by +# the Free Software Foundation; either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Lesser General Public License for more details. + +# To use this plugin, you need to first create a webhook using the +# matrix webhooks bridge at +# https://github.com/turt2live/matrix-appservice-webhooks. You'll need +# to follow the instructions to create a new webhook. You will receive a +# URL and you can then use that URL to configure this service. +# See the wiki for more details. + +import re +import requests +from json import dumps +from time import time + +from .NotifyBase import NotifyBase +from .NotifyBase import HTTP_ERROR_MAP +from ..common import NotifyImageSize +from ..utils import compat_is_basestring + +# Token required as part of the API request +VALIDATE_TOKEN = re.compile(r'[A-Za-z0-9]{64}') + +# Default User +MATRIX_DEFAULT_USER = 'apprise' + +# Extend HTTP Error Messages +MATRIX_HTTP_ERROR_MAP = HTTP_ERROR_MAP.copy() +MATRIX_HTTP_ERROR_MAP.update({ + 403: 'Unauthorized - Invalid Token.', +}) + +class MatrixNotificationMode(object): + SLACK = 0 + MATRIX = 1 + +MATRIX_NOTIFICATION_MODES = ( + MatrixNotificationMode.SLACK, + MatrixNotificationMode.MATRIX, +) + +class NotifyMatrix(NotifyBase): + """ + A wrapper for Matrix Notifications + """ + + # The default descriptive name associated with the Notification + service_name = 'Matrix' + + # The services URL + service_url = 'https://matrix.org/' + + # The default protocol + protocol = 'matrix' + + # The default secure protocol + secure_protocol = 'matrixs' + + # A URL that takes you to the setup/help of the specific protocol + setup_url = 'https://github.com/caronc/apprise/wiki/Notify_matrix' + + # The maximum allowable characters allowed in the body per message + body_maxlen = 1000 + + def __init__(self, token, mode=None, **kwargs): + """ + Initialize Matrix Object + """ + super(NotifyMatrix, self).__init__(**kwargs) + + if self.secure: + self.schema = 'https' + + else: + self.schema = 'http' + + if not isinstance(self.port, int): + self.notify_url = '%s://%s/api/v1/matrix/hook' % (self.schema, self.host) + + else: + self.notify_url = '%s://%s:%d/api/v1/matrix/hook' % (self.schema, self.host, self.port) + + if not VALIDATE_TOKEN.match(token.strip()): + self.logger.warning( + 'The API token specified (%s) is invalid.' % token, + ) + raise TypeError( + 'The API token specified (%s) is invalid.' % token, + ) + + # The token associated with the webhook + self.token = token.strip() + + if not self.user: + self.logger.warning( + 'No user was specified; using %s.' % MATRIX_DEFAULT_USER) + self.user = MATRIX_DEFAULT_USER + + if not mode: + self.logger.warning( + 'No mode was specified, using Slack mode') + self.mode = MatrixNotificationMode.SLACK + + else: + self.mode = mode + + self._re_formatting_map = { + # New lines must become the string version + r'\r\*\n': '\\n', + # Escape other special characters + r'&': '&', + r'<': '<', + r'>': '>', + } + + # Iterate over above list and store content accordingly + self._re_formatting_rules = re.compile( + r'(' + '|'.join(self._re_formatting_map.keys()) + r')', + re.IGNORECASE, + ) + + def notify(self, title, body, notify_type, **kwargs): + """ + Perform Matrix Notification + """ + + headers = { + 'User-Agent': self.app_id, + 'Content-Type': 'application/json', + } + + # error tracking (used for function return) + notify_okay = True + + # Perform Formatting + title = self._re_formatting_rules.sub( # pragma: no branch + lambda x: self._re_formatting_map[x.group()], title, + ) + body = self._re_formatting_rules.sub( # pragma: no branch + lambda x: self._re_formatting_map[x.group()], body, + ) + url = '%s/%s' % ( + self.notify_url, + self.token, + ) + + if self.mode is MatrixNotificationMode.MATRIX: + payload = self.__matrix_mode_payload(title, body, notify_type) + + else: + payload = self.__slack_mode_payload(title, body, notify_type) + + self.logger.debug('Matrix POST URL: %s (cert_verify=%r)' % ( + url, self.verify_certificate, + )) + self.logger.debug('Matrix Payload: %s' % str(payload)) + try: + r = requests.post( + url, + data=dumps(payload), + headers=headers, + verify=self.verify_certificate, + ) + if r.status_code != requests.codes.ok: + # We had a problem + try: + self.logger.warning( + 'Failed to send Matrix ' + 'notification: %s (error=%s).' % ( + MATRIX_HTTP_ERROR_MAP[r.status_code], + r.status_code)) + + except KeyError: + self.logger.warning( + 'Failed to send Matrix ' + 'notification (error=%s).' % + r.status_code) + + # Return; we're done + notify_okay = False + + else: + self.logger.info('Sent Matrix notification.') + + except requests.RequestException as e: + self.logger.warning( + 'A Connection error occured sending Matrix notification.' + ) + self.logger.debug('Socket Exception: %s' % str(e)) + notify_okay = False + + return notify_okay + + def __slack_mode_payload(self, title, body, notify_type): + # prepare JSON Object + payload = { + 'username': self.user, + # Use Markdown language + 'mrkdwn': True, + 'attachments': [{ + 'title': title, + 'text': body, + 'color': self.color(notify_type), + 'ts': time(), + 'footer': self.app_id, + }], + } + + return payload + + def __matrix_mode_payload(self, title, body, notify_type): + title = NotifyBase.escape_html(title) + body = NotifyBase.escape_html(body) + + msg = '<h4>%s</h4>%s<br/>' % (title, body) + + payload = { + 'displayName': self.user, + 'format': 'html', + 'text': msg, + } + + return payload + + @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 + + # Apply our settings now + results['token'] = NotifyBase.unquote(results['query']) + + if 'mode' in results['qsd'] and len(results['qsd']['mode']): + _map = { + 'slack': MatrixNotificationMode.SLACK, + 'matrix': MatrixNotificationMode.MATRIX, + } + try: + results['mode'] = _map[results['qsd']['mode'].lower()] + except KeyError: + pass + + return results diff --git a/apprise/plugins/__init__.py b/apprise/plugins/__init__.py index 02adfc13..dae69863 100644 --- a/apprise/plugins/__init__.py +++ b/apprise/plugins/__init__.py @@ -29,6 +29,7 @@ from .NotifyGrowl.NotifyGrowl import NotifyGrowl from .NotifyIFTTT import NotifyIFTTT from .NotifyJoin import NotifyJoin from .NotifyJSON import NotifyJSON +from .NotifyMatrix import NotifyMatrix from .NotifyMatterMost import NotifyMatterMost from .NotifyProwl import NotifyProwl from .NotifyPushalot import NotifyPushalot diff --git a/test/test_rest_plugins.py b/test/test_rest_plugins.py index 69a6dbe2..ba6c0a04 100644 --- a/test/test_rest_plugins.py +++ b/test/test_rest_plugins.py @@ -542,6 +542,63 @@ TEST_URLS = ( 'test_requests_exceptions': True, }), + ################################## + # NotifyMatrix + ################################## + ('matrix://', { + 'instance': None, + }), + ('matrixs://', { + 'instance': None, + }), + # No token + ('matrix://localhost', { + 'instance': TypeError, + }), + ('matrix://user@localhost', { + 'instance': TypeError, + }), + ('matrix://localhost/%s' % ('a' * 64), { + 'instance': plugins.NotifyMatrix, + }), + # Name and token + ('matrix://user@localhost/%s' % ('a' * 64), { + 'instance': plugins.NotifyMatrix, + }), + # Name, port and token (secure) + ('matrixs://user@localhost:9000/%s' % ('a' * 64), { + 'instance': plugins.NotifyMatrix, + }), + # Name, port, token and slack mode + ('matrix://user@localhost:9000/%s?mode=slack' % ('a' * 64), { + 'instance': plugins.NotifyMatrix, + }), + # Name, port, token and matrix mode + ('matrix://user@localhost:9000/%s?mode=matrix' % ('a' * 64), { + 'instance': plugins.NotifyMatrix, + }), + # Name, port, token and invalid mode + ('matrix://user@localhost:9000/%s?mode=foo' % ('a' * 64), { + 'instance': plugins.NotifyMatrix, + }), + ('matrix://user@localhost:9000/%s?mode=slack' % ('a' * 64), { + 'instance': plugins.NotifyMatrix, + 'response': False, + 'requests_response_code': requests.codes.internal_server_error, + }), + ('matrix://user@localhost:9000/%s?mode=slack' % ('a' * 64), { + 'instance': plugins.NotifyMatrix, + # throw a bizzare code forcing us to fail to look it up + 'response': False, + 'requests_response_code': 999, + }), + ('matrix://user@localhost:9000/%s?mode=slack' % ('a' * 64), { + 'instance': plugins.NotifyMatrix, + # Throws a series of connection and transfer exceptions when this flag + # is set and tests that we gracfully handle them + 'test_requests_exceptions': True, + }), + ################################## # NotifyMatterMost ##################################