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'&': '&amp;',
+            r'<': '&lt;',
+            r'>': '&gt;',
+        }
+
+        # 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
     ##################################