From 207db69e1abe2ca66caad74ed770f5ae97f8e96e Mon Sep 17 00:00:00 2001 From: Chris Caron Date: Sun, 13 Aug 2023 20:00:34 -0400 Subject: [PATCH] Added Matrix Attachment support (#921) --- apprise/plugins/NotifyMatrix.py | 160 +++++++++++++++++++--- test/test_plugin_matrix.py | 234 +++++++++++++++++++++++++++++++- 2 files changed, 376 insertions(+), 18 deletions(-) diff --git a/apprise/plugins/NotifyMatrix.py b/apprise/plugins/NotifyMatrix.py index 78019715..00b9a2d6 100644 --- a/apprise/plugins/NotifyMatrix.py +++ b/apprise/plugins/NotifyMatrix.py @@ -53,8 +53,11 @@ from ..utils import validate_regex from ..AppriseLocale import gettext_lazy as _ # Define default path -MATRIX_V2_API_PATH = '/_matrix/client/r0' MATRIX_V1_WEBHOOK_PATH = '/api/v1/matrix/hook' +MATRIX_V2_API_PATH = '/_matrix/client/r0' +MATRIX_V3_API_PATH = '/_matrix/client/v3' +MATRIX_V3_MEDIA_PATH = '/_matrix/media/v3' +MATRIX_V2_MEDIA_PATH = '/_matrix/media/r0' # Extend HTTP Error Messages MATRIX_HTTP_ERROR_MAP = { @@ -88,6 +91,21 @@ MATRIX_MESSAGE_TYPES = ( ) +class MatrixVersion: + # Version 2 + V2 = "2" + + # Version 3 + V3 = "3" + + +# webhook modes are placed into this list for validation purposes +MATRIX_VERSIONS = ( + MatrixVersion.V2, + MatrixVersion.V3, +) + + class MatrixWebhookMode: # Webhook Mode is disabled DISABLED = "off" @@ -128,6 +146,9 @@ class NotifyMatrix(NotifyBase): # The default secure protocol secure_protocol = 'matrixs' + # Support Attachments + attachment_support = True + # A URL that takes you to the setup/help of the specific protocol setup_url = 'https://github.com/caronc/apprise/wiki/Notify_matrix' @@ -147,6 +168,9 @@ class NotifyMatrix(NotifyBase): # Throttle a wee-bit to avoid thrashing request_rate_per_sec = 0.5 + # Our Matrix API Version + matrix_api_version = '3' + # How many retry attempts we'll make in the event the server asks us to # throttle back. default_retries = 2 @@ -234,6 +258,12 @@ class NotifyMatrix(NotifyBase): 'values': MATRIX_WEBHOOK_MODES, 'default': MatrixWebhookMode.DISABLED, }, + 'version': { + 'name': _('Matrix API Verion'), + 'type': 'choice:string', + 'values': MATRIX_VERSIONS, + 'default': MatrixVersion.V3, + }, 'msgtype': { 'name': _('Message Type'), 'type': 'choice:string', @@ -248,7 +278,7 @@ class NotifyMatrix(NotifyBase): }, }) - def __init__(self, targets=None, mode=None, msgtype=None, + def __init__(self, targets=None, mode=None, msgtype=None, version=None, include_image=False, **kwargs): """ Initialize Matrix Object @@ -282,6 +312,14 @@ class NotifyMatrix(NotifyBase): self.logger.warning(msg) raise TypeError(msg) + # Setup our version + self.version = self.template_args['version']['default'] \ + if not isinstance(version, str) else version + if self.version not in MATRIX_VERSIONS: + msg = 'The version specified ({}) is invalid.'.format(version) + self.logger.warning(msg) + raise TypeError(msg) + # Setup our message type self.msgtype = self.template_args['msgtype']['default'] \ if not isinstance(msgtype, str) else msgtype.lower() @@ -521,7 +559,8 @@ class NotifyMatrix(NotifyBase): return payload def _send_server_notification(self, body, title='', - notify_type=NotifyType.INFO, **kwargs): + notify_type=NotifyType.INFO, attach=None, + **kwargs): """ Perform Direct Matrix Server Notification (no webhook) """ @@ -548,6 +587,13 @@ class NotifyMatrix(NotifyBase): # Initiaize our error tracking has_error = False + attachments = None + if attach and self.attachment_support: + attachments = self._send_attachments(attach) + if not attachments: + # take an early exit + return False + while len(rooms) > 0: # Get our room @@ -568,6 +614,10 @@ class NotifyMatrix(NotifyBase): image_url = None if not self.include_image else \ self.image_url(notify_type) + # Build our path + path = '/rooms/{}/send/m.room.message'.format( + NotifyMatrix.quote(room_id)) + if image_url: # Define our payload image_payload = { @@ -575,9 +625,6 @@ class NotifyMatrix(NotifyBase): 'url': image_url, 'body': '{}'.format(notify_type if not title else title), } - # Build our path - path = '/rooms/{}/send/m.room.message'.format( - NotifyMatrix.quote(room_id)) # Post our content postokay, response = self._fetch(path, payload=image_payload) @@ -586,6 +633,14 @@ class NotifyMatrix(NotifyBase): has_error = True continue + if attachments: + for attachment in attachments: + postokay, response = self._fetch(path, payload=attachment) + if not postokay: + # Mark our failure + has_error = True + continue + # Define our payload payload = { 'msgtype': 'm.{}'.format(self.msgtype), @@ -615,10 +670,6 @@ class NotifyMatrix(NotifyBase): ) }) - # Build our path - path = '/rooms/{}/send/m.room.message'.format( - NotifyMatrix.quote(room_id)) - # Post our content postokay, response = self._fetch(path, payload=payload) if not postokay: @@ -632,6 +683,44 @@ class NotifyMatrix(NotifyBase): return not has_error + def _send_attachments(self, attach): + """ + Posts all of the provided attachments + """ + + payloads = [] + for attachment in attach: + if not attachment: + # invalid attachment (bad file) + return False + + if not re.match(r'^image/', attachment.mimetype, re.I): + # unsuppored at this time + continue + + postokay, response = \ + self._fetch('/upload', attachment=attachment) + if not (postokay and isinstance(response, dict)): + # Failed to perform upload + return False + + # If we get here, we'll have a response that looks like: + # { + # "content_uri": "mxc://example.com/a-unique-key" + # } + + # Prepare our payload + payloads.append({ + "info": { + "mimetype": attachment.mimetype, + }, + "msgtype": "m.image", + "body": "tta.webp", + "url": response.get('content_uri'), + }) + + return payloads + def _register(self): """ Register with the service if possible. @@ -970,7 +1059,8 @@ class NotifyMatrix(NotifyBase): return None - def _fetch(self, path, payload=None, params=None, method='POST'): + def _fetch(self, path, payload=None, params=None, attachment=None, + method='POST'): """ Wrapper to request.post() to manage it's response better and make the send() function cleaner and easier to maintain. @@ -983,6 +1073,7 @@ class NotifyMatrix(NotifyBase): headers = { 'User-Agent': self.app_id, 'Content-Type': 'application/json', + 'Accept': 'application/json', } if self.access_token is not None: @@ -991,13 +1082,32 @@ class NotifyMatrix(NotifyBase): default_port = 443 if self.secure else 80 url = \ - '{schema}://{hostname}{port}{matrix_api}{path}'.format( + '{schema}://{hostname}{port}'.format( schema='https' if self.secure else 'http', hostname=self.host, port='' if self.port is None - or self.port == default_port else f':{self.port}', - matrix_api=MATRIX_V2_API_PATH, - path=path) + or self.port == default_port else f':{self.port}') + + if path == '/upload': + if self.version == MatrixVersion.V3: + url += MATRIX_V3_MEDIA_PATH + path + + else: + url += MATRIX_V2_MEDIA_PATH + path + + params = {'filename': attachment.name} + with open(attachment.path, 'rb') as fp: + payload = fp.read() + + # Update our content type + headers['Content-Type'] = attachment.mimetype + + else: + if self.version == MatrixVersion.V3: + url += MATRIX_V3_API_PATH + path + + else: + url += MATRIX_V2_API_PATH + path # Our response object response = {} @@ -1024,7 +1134,7 @@ class NotifyMatrix(NotifyBase): try: r = fn( url, - data=dumps(payload), + data=dumps(payload) if not attachment else payload, params=params, headers=headers, verify=self.verify_certificate, @@ -1095,6 +1205,13 @@ class NotifyMatrix(NotifyBase): # Return; we're done return (False, response) + except (OSError, IOError) as e: + self.logger.warning( + 'An I/O error occurred while reading {}.'.format( + attachment.name if attachment else 'unknown file')) + self.logger.debug('I/O Exception: %s' % str(e)) + return (False, {}) + return (True, response) # If we get here, we ran out of retries @@ -1161,6 +1278,7 @@ class NotifyMatrix(NotifyBase): params = { 'image': 'yes' if self.include_image else 'no', 'mode': self.mode, + 'version': self.version, 'msgtype': self.msgtype, } @@ -1258,6 +1376,14 @@ class NotifyMatrix(NotifyBase): if 'token' in results['qsd'] and len(results['qsd']['token']): results['password'] = NotifyMatrix.unquote(results['qsd']['token']) + # Support the use of the version= or v= keyword + if 'version' in results['qsd'] and len(results['qsd']['version']): + results['version'] = \ + NotifyMatrix.unquote(results['qsd']['version']) + + elif 'v' in results['qsd'] and len(results['qsd']['v']): + results['version'] = NotifyMatrix.unquote(results['qsd']['v']) + return results @staticmethod @@ -1267,7 +1393,7 @@ class NotifyMatrix(NotifyBase): """ result = re.match( - r'^https?://webhooks\.t2bot\.io/api/v1/matrix/hook/' + r'^https?://webhooks\.t2bot\.io/api/v[0-9]+/matrix/hook/' r'(?P[A-Z0-9_-]+)/?' r'(?P\?.+)?$', url, re.I) diff --git a/test/test_plugin_matrix.py b/test/test_plugin_matrix.py index 8e8423e3..624ed99e 100644 --- a/test/test_plugin_matrix.py +++ b/test/test_plugin_matrix.py @@ -32,9 +32,10 @@ from unittest import mock +import os import requests import pytest -from apprise import AppriseAsset +from apprise import Apprise, AppriseAsset, AppriseAttachment, NotifyType from json import dumps from apprise.plugins.NotifyMatrix import NotifyMatrix @@ -44,6 +45,17 @@ from helpers import AppriseURLTester import logging logging.disable(logging.CRITICAL) +MATRIX_GOOD_RESPONSE = dumps({ + 'room_id': '!abc123:localhost', + 'room_alias': '#abc123:localhost', + 'joined_rooms': ['!abc123:localhost', '!def456:localhost'], + 'access_token': 'abcd1234', + 'home_server': 'localhost', +}) + +# Attachment Directory +TEST_VAR_DIR = os.path.join(os.path.dirname(__file__), 'var') + # Our Testing URLs apprise_url_tests = ( ################################## @@ -97,6 +109,24 @@ apprise_url_tests = ( # user and token correctly specified with webhook 'instance': NotifyMatrix, }), + ('matrix://user:token@localhost:123/#general/?version=3', { + # Provide version over-ride (using version=) + 'instance': NotifyMatrix, + # Our response expected server response + 'requests_response_text': MATRIX_GOOD_RESPONSE, + 'privacy_url': 'matrix://user:****@localhost:123', + }), + ('matrixs://user:token@localhost/#general?v=2', { + # Provide version over-ride (using v=) + 'instance': NotifyMatrix, + # Our response expected server response + 'requests_response_text': MATRIX_GOOD_RESPONSE, + 'privacy_url': 'matrixs://user:****@localhost', + }), + ('matrix://user:token@localhost:123/#general/?v=invalid', { + # Invalid version specified + 'instance': TypeError + }), ('matrix://user:token@localhost?mode=slack&format=text', { # user and token correctly specified with webhook 'instance': NotifyMatrix, @@ -842,3 +872,205 @@ def test_plugin_matrix_image_errors(mock_post, mock_get): assert obj.access_token is None assert obj.notify('test', 'test') is True + + +@mock.patch('requests.get') +@mock.patch('requests.post') +def test_plugin_matrix_attachments_api_v3(mock_post, mock_get): + """ + NotifyMatrix() Attachment Checks (v3) + + """ + + # Prepare a good response + response = mock.Mock() + response.status_code = requests.codes.ok + response.content = MATRIX_GOOD_RESPONSE.encode('utf-8') + + # Prepare a bad response + bad_response = mock.Mock() + bad_response.status_code = requests.codes.internal_server_error + + # Prepare Mock return object + mock_post.return_value = response + mock_get.return_value = response + + # Instantiate our object + obj = Apprise.instantiate('matrix://user:pass@localhost/#general?v=3') + + # attach our content + attach = AppriseAttachment(os.path.join(TEST_VAR_DIR, 'apprise-test.gif')) + + assert obj.notify( + body='body', title='title', notify_type=NotifyType.INFO, + attach=attach) is True + + attach = AppriseAttachment(os.path.join(TEST_VAR_DIR, 'apprise-test.gif')) + + # Test our call count + assert mock_post.call_count == 5 + assert mock_post.call_args_list[0][0][0] == \ + 'http://localhost/_matrix/client/v3/login' + assert mock_post.call_args_list[1][0][0] == \ + 'http://localhost/_matrix/media/v3/upload' + assert mock_post.call_args_list[2][0][0] == \ + 'http://localhost/_matrix/client/v3/join/%23general%3Alocalhost' + assert mock_post.call_args_list[3][0][0] == \ + 'http://localhost/_matrix/client/v3/rooms/%21abc123%3Alocalhost/' \ + 'send/m.room.message' + assert mock_post.call_args_list[4][0][0] == \ + 'http://localhost/_matrix/client/v3/rooms/%21abc123%3Alocalhost/' \ + 'send/m.room.message' + + # Attach an unsupported file type + attach = AppriseAttachment( + os.path.join(TEST_VAR_DIR, 'apprise-archive.zip')) + assert obj.notify( + body='body', title='title', notify_type=NotifyType.INFO, + attach=attach) is False + + # An invalid attachment will cause a failure + path = os.path.join(TEST_VAR_DIR, '/invalid/path/to/an/invalid/file.jpg') + attach = AppriseAttachment(path) + assert obj.notify( + body='body', title='title', notify_type=NotifyType.INFO, + attach=path) is False + + # update our attachment to be valid + attach = AppriseAttachment(os.path.join(TEST_VAR_DIR, 'apprise-test.gif')) + + mock_post.return_value = None + # Throw an exception on the first call to requests.post() + for side_effect in (requests.RequestException(), OSError(), bad_response): + mock_post.side_effect = [side_effect] + + # We'll fail now because of our error handling + assert obj.send(body="test", attach=attach) is False + + # Throw an exception on the second call to requests.post() + for side_effect in (requests.RequestException(), OSError(), bad_response): + mock_post.side_effect = [response, side_effect] + + # We'll fail now because of our error handling + assert obj.send(body="test", attach=attach) is False + + # handle a bad response + bad_response = mock.Mock() + bad_response.status_code = requests.codes.internal_server_error + mock_post.side_effect = [response, bad_response] + + # We'll fail now because of an internal exception + assert obj.send(body="test", attach=attach) is False + + +@mock.patch('requests.get') +@mock.patch('requests.post') +def test_plugin_matrix_attachments_api_v2(mock_post, mock_get): + """ + NotifyMatrix() Attachment Checks (v2) + + """ + + # Prepare a good response + response = mock.Mock() + response.status_code = requests.codes.ok + response.content = MATRIX_GOOD_RESPONSE.encode('utf-8') + + # Prepare a bad response + bad_response = mock.Mock() + bad_response.status_code = requests.codes.internal_server_error + + # Prepare Mock return object + mock_post.return_value = response + mock_get.return_value = response + + # Instantiate our object + obj = Apprise.instantiate('matrix://user:pass@localhost/#general?v=3') + + # attach our content + attach = AppriseAttachment(os.path.join(TEST_VAR_DIR, 'apprise-test.gif')) + + assert obj.notify( + body='body', title='title', notify_type=NotifyType.INFO, + attach=attach) is True + + attach = AppriseAttachment(os.path.join(TEST_VAR_DIR, 'apprise-test.gif')) + + # Attach an unsupported file + mock_post.return_value = response + mock_get.return_value = response + mock_post.side_effect = None + mock_get.side_effect = None + + # Force a object removal (thus a logout call) + del obj + + # Reset our object + mock_post.reset_mock() + mock_get.reset_mock() + + # Instantiate our object + obj = Apprise.instantiate('matrixs://user:pass@localhost/#general?v=2') + + assert obj.notify( + body='body', title='title', notify_type=NotifyType.INFO, + attach=attach) is True + + # Test our call count + assert mock_post.call_count == 5 + assert mock_post.call_args_list[0][0][0] == \ + 'https://localhost/_matrix/client/r0/login' + assert mock_post.call_args_list[1][0][0] == \ + 'https://localhost/_matrix/media/r0/upload' + assert mock_post.call_args_list[2][0][0] == \ + 'https://localhost/_matrix/client/r0/join/%23general%3Alocalhost' + assert mock_post.call_args_list[3][0][0] == \ + 'https://localhost/_matrix/client/r0/rooms/%21abc123%3Alocalhost/' \ + 'send/m.room.message' + assert mock_post.call_args_list[4][0][0] == \ + 'https://localhost/_matrix/client/r0/rooms/%21abc123%3Alocalhost/' \ + 'send/m.room.message' + + # Attach an unsupported file type + attach = AppriseAttachment( + os.path.join(TEST_VAR_DIR, 'apprise-archive.zip')) + assert obj.notify( + body='body', title='title', notify_type=NotifyType.INFO, + attach=attach) is False + + # An invalid attachment will cause a failure + path = os.path.join(TEST_VAR_DIR, '/invalid/path/to/an/invalid/file.jpg') + attach = AppriseAttachment(path) + assert obj.notify( + body='body', title='title', notify_type=NotifyType.INFO, + attach=path) is False + + # update our attachment to be valid + attach = AppriseAttachment(os.path.join(TEST_VAR_DIR, 'apprise-test.gif')) + + mock_post.return_value = None + mock_get.return_value = None + + # Throw an exception on the first call to requests.post() + for side_effect in (requests.RequestException(), OSError(), bad_response): + mock_post.side_effect = [side_effect] + mock_get.side_effect = [side_effect] + + assert obj.send(body="test", attach=attach) is False + + # Throw an exception on the second call to requests.post() + for side_effect in (requests.RequestException(), OSError(), bad_response): + mock_post.side_effect = [response, side_effect] + mock_get.side_effect = [side_effect] + + # We'll fail now because of our error handling + assert obj.send(body="test", attach=attach) is False + + # handle a bad response + bad_response = mock.Mock() + bad_response.status_code = requests.codes.internal_server_error + mock_post.side_effect = [response, bad_response] + mock_get.side_effect = [response, bad_response] + + # We'll fail now because of an internal exception + assert obj.send(body="test", attach=attach) is False