Added Matrix Attachment support (#921)

This commit is contained in:
Chris Caron 2023-08-13 20:00:34 -04:00 committed by GitHub
parent c56d4c500d
commit 207db69e1a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
2 changed files with 376 additions and 18 deletions

View File

@ -53,8 +53,11 @@ from ..utils import validate_regex
from ..AppriseLocale import gettext_lazy as _ from ..AppriseLocale import gettext_lazy as _
# Define default path # Define default path
MATRIX_V2_API_PATH = '/_matrix/client/r0'
MATRIX_V1_WEBHOOK_PATH = '/api/v1/matrix/hook' 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 # Extend HTTP Error Messages
MATRIX_HTTP_ERROR_MAP = { 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: class MatrixWebhookMode:
# Webhook Mode is disabled # Webhook Mode is disabled
DISABLED = "off" DISABLED = "off"
@ -128,6 +146,9 @@ class NotifyMatrix(NotifyBase):
# The default secure protocol # The default secure protocol
secure_protocol = 'matrixs' secure_protocol = 'matrixs'
# Support Attachments
attachment_support = True
# A URL that takes you to the setup/help of the specific protocol # A URL that takes you to the setup/help of the specific protocol
setup_url = 'https://github.com/caronc/apprise/wiki/Notify_matrix' setup_url = 'https://github.com/caronc/apprise/wiki/Notify_matrix'
@ -147,6 +168,9 @@ class NotifyMatrix(NotifyBase):
# Throttle a wee-bit to avoid thrashing # Throttle a wee-bit to avoid thrashing
request_rate_per_sec = 0.5 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 # How many retry attempts we'll make in the event the server asks us to
# throttle back. # throttle back.
default_retries = 2 default_retries = 2
@ -234,6 +258,12 @@ class NotifyMatrix(NotifyBase):
'values': MATRIX_WEBHOOK_MODES, 'values': MATRIX_WEBHOOK_MODES,
'default': MatrixWebhookMode.DISABLED, 'default': MatrixWebhookMode.DISABLED,
}, },
'version': {
'name': _('Matrix API Verion'),
'type': 'choice:string',
'values': MATRIX_VERSIONS,
'default': MatrixVersion.V3,
},
'msgtype': { 'msgtype': {
'name': _('Message Type'), 'name': _('Message Type'),
'type': 'choice:string', '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): include_image=False, **kwargs):
""" """
Initialize Matrix Object Initialize Matrix Object
@ -282,6 +312,14 @@ class NotifyMatrix(NotifyBase):
self.logger.warning(msg) self.logger.warning(msg)
raise TypeError(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 # Setup our message type
self.msgtype = self.template_args['msgtype']['default'] \ self.msgtype = self.template_args['msgtype']['default'] \
if not isinstance(msgtype, str) else msgtype.lower() if not isinstance(msgtype, str) else msgtype.lower()
@ -521,7 +559,8 @@ class NotifyMatrix(NotifyBase):
return payload return payload
def _send_server_notification(self, body, title='', 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) Perform Direct Matrix Server Notification (no webhook)
""" """
@ -548,6 +587,13 @@ class NotifyMatrix(NotifyBase):
# Initiaize our error tracking # Initiaize our error tracking
has_error = False 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: while len(rooms) > 0:
# Get our room # Get our room
@ -568,6 +614,10 @@ class NotifyMatrix(NotifyBase):
image_url = None if not self.include_image else \ image_url = None if not self.include_image else \
self.image_url(notify_type) self.image_url(notify_type)
# Build our path
path = '/rooms/{}/send/m.room.message'.format(
NotifyMatrix.quote(room_id))
if image_url: if image_url:
# Define our payload # Define our payload
image_payload = { image_payload = {
@ -575,9 +625,6 @@ class NotifyMatrix(NotifyBase):
'url': image_url, 'url': image_url,
'body': '{}'.format(notify_type if not title else title), '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 # Post our content
postokay, response = self._fetch(path, payload=image_payload) postokay, response = self._fetch(path, payload=image_payload)
@ -586,6 +633,14 @@ class NotifyMatrix(NotifyBase):
has_error = True has_error = True
continue 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 # Define our payload
payload = { payload = {
'msgtype': 'm.{}'.format(self.msgtype), '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 # Post our content
postokay, response = self._fetch(path, payload=payload) postokay, response = self._fetch(path, payload=payload)
if not postokay: if not postokay:
@ -632,6 +683,44 @@ class NotifyMatrix(NotifyBase):
return not has_error 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): def _register(self):
""" """
Register with the service if possible. Register with the service if possible.
@ -970,7 +1059,8 @@ class NotifyMatrix(NotifyBase):
return None 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 Wrapper to request.post() to manage it's response better and make
the send() function cleaner and easier to maintain. the send() function cleaner and easier to maintain.
@ -983,6 +1073,7 @@ class NotifyMatrix(NotifyBase):
headers = { headers = {
'User-Agent': self.app_id, 'User-Agent': self.app_id,
'Content-Type': 'application/json', 'Content-Type': 'application/json',
'Accept': 'application/json',
} }
if self.access_token is not None: if self.access_token is not None:
@ -991,13 +1082,32 @@ class NotifyMatrix(NotifyBase):
default_port = 443 if self.secure else 80 default_port = 443 if self.secure else 80
url = \ url = \
'{schema}://{hostname}{port}{matrix_api}{path}'.format( '{schema}://{hostname}{port}'.format(
schema='https' if self.secure else 'http', schema='https' if self.secure else 'http',
hostname=self.host, hostname=self.host,
port='' if self.port is None port='' if self.port is None
or self.port == default_port else f':{self.port}', or self.port == default_port else f':{self.port}')
matrix_api=MATRIX_V2_API_PATH,
path=path) 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 # Our response object
response = {} response = {}
@ -1024,7 +1134,7 @@ class NotifyMatrix(NotifyBase):
try: try:
r = fn( r = fn(
url, url,
data=dumps(payload), data=dumps(payload) if not attachment else payload,
params=params, params=params,
headers=headers, headers=headers,
verify=self.verify_certificate, verify=self.verify_certificate,
@ -1095,6 +1205,13 @@ class NotifyMatrix(NotifyBase):
# Return; we're done # Return; we're done
return (False, response) 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) return (True, response)
# If we get here, we ran out of retries # If we get here, we ran out of retries
@ -1161,6 +1278,7 @@ class NotifyMatrix(NotifyBase):
params = { params = {
'image': 'yes' if self.include_image else 'no', 'image': 'yes' if self.include_image else 'no',
'mode': self.mode, 'mode': self.mode,
'version': self.version,
'msgtype': self.msgtype, 'msgtype': self.msgtype,
} }
@ -1258,6 +1376,14 @@ class NotifyMatrix(NotifyBase):
if 'token' in results['qsd'] and len(results['qsd']['token']): if 'token' in results['qsd'] and len(results['qsd']['token']):
results['password'] = NotifyMatrix.unquote(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 return results
@staticmethod @staticmethod
@ -1267,7 +1393,7 @@ class NotifyMatrix(NotifyBase):
""" """
result = re.match( 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<webhook_token>[A-Z0-9_-]+)/?' r'(?P<webhook_token>[A-Z0-9_-]+)/?'
r'(?P<params>\?.+)?$', url, re.I) r'(?P<params>\?.+)?$', url, re.I)

View File

@ -32,9 +32,10 @@
from unittest import mock from unittest import mock
import os
import requests import requests
import pytest import pytest
from apprise import AppriseAsset from apprise import Apprise, AppriseAsset, AppriseAttachment, NotifyType
from json import dumps from json import dumps
from apprise.plugins.NotifyMatrix import NotifyMatrix from apprise.plugins.NotifyMatrix import NotifyMatrix
@ -44,6 +45,17 @@ from helpers import AppriseURLTester
import logging import logging
logging.disable(logging.CRITICAL) 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 # Our Testing URLs
apprise_url_tests = ( apprise_url_tests = (
################################## ##################################
@ -97,6 +109,24 @@ apprise_url_tests = (
# user and token correctly specified with webhook # user and token correctly specified with webhook
'instance': NotifyMatrix, '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', { ('matrix://user:token@localhost?mode=slack&format=text', {
# user and token correctly specified with webhook # user and token correctly specified with webhook
'instance': NotifyMatrix, 'instance': NotifyMatrix,
@ -842,3 +872,205 @@ def test_plugin_matrix_image_errors(mock_post, mock_get):
assert obj.access_token is None assert obj.access_token is None
assert obj.notify('test', 'test') is True 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