From 409fa6b05eb082ae61ed3d66bda3eb19e6d9cc34 Mon Sep 17 00:00:00 2001 From: Chris Caron Date: Sat, 1 Jun 2024 20:28:35 -0400 Subject: [PATCH] Turn large ntfy messages into a attachments --- apprise/apprise_attachment.py | 9 +- apprise/attachment/base.py | 44 ++++++- apprise/attachment/file.py | 3 +- apprise/attachment/http.py | 2 +- apprise/attachment/memory.py | 212 +++++++++++++++++++++++++++++++ apprise/plugins/ntfy.py | 48 ++++++- test/test_apprise_attachments.py | 30 ++++- test/test_attach_base.py | 9 -- test/test_attach_file.py | 3 +- test/test_attach_http.py | 2 +- test/test_attach_memory.py | 205 ++++++++++++++++++++++++++++++ test/test_plugin_ntfy.py | 45 +++++++ 12 files changed, 575 insertions(+), 37 deletions(-) create mode 100644 apprise/attachment/memory.py create mode 100644 test/test_attach_memory.py diff --git a/apprise/apprise_attachment.py b/apprise/apprise_attachment.py index 3c33f9e73..ecf415ec9 100644 --- a/apprise/apprise_attachment.py +++ b/apprise/apprise_attachment.py @@ -142,13 +142,8 @@ def add(self, attachments, asset=None, cache=None): # prepare default asset asset = self.asset - if isinstance(attachments, AttachBase): - # Go ahead and just add our attachments into our list - self.attachments.append(attachments) - return True - - elif isinstance(attachments, str): - # Save our path + if isinstance(attachments, (AttachBase, str)): + # store our instance attachments = (attachments, ) elif not isinstance(attachments, (tuple, set, list)): diff --git a/apprise/attachment/base.py b/apprise/attachment/base.py index 71e3a4d0d..6ae9d3aa6 100644 --- a/apprise/attachment/base.py +++ b/apprise/attachment/base.py @@ -148,6 +148,9 @@ def __init__(self, name=None, mimetype=None, cache=None, **kwargs): # Absolute path to attachment self.download_path = None + # Track open file pointers + self.__pointers = set() + # Set our cache flag; it can be True, False, None, or a (positive) # integer... nothing else if cache is not None: @@ -226,15 +229,14 @@ def mimetype(self): Content is cached once determied to prevent overhead of future calls. """ + if not self.exists(): + # we could not obtain our attachment + return None if self._mimetype: # return our pre-calculated cached content return self._mimetype - if not self.exists(): - # we could not obtain our attachment - return None - if not self.detected_mimetype: # guess_type() returns: (type, encoding) and sets type to None # if it can't otherwise determine it. @@ -258,6 +260,9 @@ def exists(self, retrieve_if_missing=True): Simply returns true if the object has downloaded and stored the attachment AND the attachment has not expired. """ + if self.location == ContentLocation.INACCESSIBLE: + # our content is inaccessible + return False cache = self.template_args['cache']['default'] \ if self.cache is None else self.cache @@ -295,6 +300,11 @@ def invalidate(self): - download_path: Must contain a absolute path to content - detected_mimetype: Should identify mimetype of content """ + + # Remove all open pointers + while self.__pointers: + self.__pointers.pop().close() + self.detected_name = None self.download_path = None self.detected_mimetype = None @@ -314,6 +324,26 @@ def download(self): raise NotImplementedError( "download() is implimented by the child class.") + def open(self, mode='rb'): + """ + return our file pointer and track it (we'll auto close later + """ + pointer = open(self.path, mode=mode) + self.__pointers.add(pointer) + return pointer + + def __enter__(self): + """ + support with keyword + """ + return self.open() + + def __exit__(self, value_type, value, traceback): + """ + stub to do nothing; but support exit of with statement gracefully + """ + return + @staticmethod def parse_url(url, verify_host=True, mimetype_db=None, sanitize=True): """Parses the URL and returns it broken apart into a dictionary. @@ -376,3 +406,9 @@ def __bool__(self): True is returned if our content was downloaded correctly. """ return True if self.path else False + + def __del__(self): + """ + Perform any house cleaning + """ + self.invalidate() diff --git a/apprise/attachment/file.py b/apprise/attachment/file.py index c48a707ae..88d8f6e1b 100644 --- a/apprise/attachment/file.py +++ b/apprise/attachment/file.py @@ -78,7 +78,8 @@ def url(self, privacy=False, *args, **kwargs): return 'file://{path}{params}'.format( path=self.quote(self.dirty_path), - params='?{}'.format(self.urlencode(params)) if params else '', + params='?{}'.format(self.urlencode(params, safe='/')) + if params else '', ) def download(self, **kwargs): diff --git a/apprise/attachment/http.py b/apprise/attachment/http.py index aa075d671..870f7cc2b 100644 --- a/apprise/attachment/http.py +++ b/apprise/attachment/http.py @@ -352,7 +352,7 @@ def url(self, privacy=False, *args, **kwargs): port='' if self.port is None or self.port == default_port else ':{}'.format(self.port), fullpath=self.quote(self.fullpath, safe='/'), - params=self.urlencode(params), + params=self.urlencode(params, safe='/'), ) @staticmethod diff --git a/apprise/attachment/memory.py b/apprise/attachment/memory.py new file mode 100644 index 000000000..94645f26f --- /dev/null +++ b/apprise/attachment/memory.py @@ -0,0 +1,212 @@ +# -*- coding: utf-8 -*- +# BSD 2-Clause License +# +# Apprise - Push Notification Library. +# Copyright (c) 2024, Chris Caron +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# +# 1. Redistributions of source code must retain the above copyright notice, +# this list of conditions and the following disclaimer. +# +# 2. Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions and the following disclaimer in the documentation +# and/or other materials provided with the distribution. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE +# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +# POSSIBILITY OF SUCH DAMAGE. + +import re +import os +import io +from .base import AttachBase +from ..common import ContentLocation +from ..locale import gettext_lazy as _ +import uuid + + +class AttachMemory(AttachBase): + """ + A wrapper for Memory based attachment sources + """ + + # The default descriptive name associated with the service + service_name = _('Memory') + + # The default protocol + protocol = 'memory' + + # Content is local to the same location as the apprise instance + # being called (server-side) + location = ContentLocation.LOCAL + + def __init__(self, content=None, name=None, mimetype=None, + encoding='utf-8', **kwargs): + """ + Initialize Memory Based Attachment Object + + """ + # Create our BytesIO object + self._data = io.BytesIO() + + if content is None: + # Empty; do nothing + pass + + elif isinstance(content, str): + content = content.encode(encoding) + if mimetype is None: + mimetype = 'text/plain' + + if not name: + # Generate a unique filename + name = str(uuid.uuid4()) + '.txt' + + elif not isinstance(content, bytes): + raise TypeError( + 'Provided content for memory attachment is invalid') + + # Store our content + if content: + self._data.write(content) + + if mimetype is None: + # Default mimetype + mimetype = 'application/octet-stream' + + if not name: + # Generate a unique filename + name = str(uuid.uuid4()) + '.dat' + + # Initialize our base object + super().__init__(name=name, mimetype=mimetype, **kwargs) + + return + + def url(self, privacy=False, *args, **kwargs): + """ + Returns the URL built dynamically based on specified arguments. + """ + + # Define any URL parameters + params = { + 'mime': self._mimetype, + } + + return 'memory://{name}?{params}'.format( + name=self.quote(self._name), + params=self.urlencode(params, safe='/') + ) + + def open(self, *args, **kwargs): + """ + return our memory object + """ + # Return our object + self._data.seek(0, 0) + return self._data + + def __enter__(self): + """ + support with clause + """ + # Return our object + self._data.seek(0, 0) + return self._data + + def download(self, **kwargs): + """ + Handle memory download() call + """ + + if self.location == ContentLocation.INACCESSIBLE: + # our content is inaccessible + return False + + if self.max_file_size > 0 and len(self) > self.max_file_size: + # The content to attach is to large + self.logger.error( + 'Content exceeds allowable maximum memory size ' + '({}KB): {}'.format( + int(self.max_file_size / 1024), self.url(privacy=True))) + + # Return False (signifying a failure) + return False + + return True + + def invalidate(self): + """ + Removes data + """ + self._data.truncate(0) + return + + def exists(self): + """ + over-ride exists() call + """ + size = len(self) + return True if self.location != ContentLocation.INACCESSIBLE \ + and size > 0 and ( + self.max_file_size <= 0 or + (self.max_file_size > 0 and size <= self.max_file_size)) \ + else False + + @staticmethod + def parse_url(url): + """ + Parses the URL so that we can handle all different file paths + and return it as our path object + + """ + + results = AttachBase.parse_url(url, verify_host=False) + if not results: + # We're done early; it's not a good URL + return results + + if 'name' not in results: + # Allow fall-back to be from URL + match = re.match(r'memory://(?P[^?]+)(\?.*)?', url, re.I) + if match: + # Store our filename only (ignore any defined paths) + results['name'] = \ + os.path.basename(AttachMemory.unquote(match.group('path'))) + return results + + @property + def path(self): + """ + return the filename + """ + if not self.exists(): + # we could not obtain our path + return None + + return self._name + + def __len__(self): + """ + Returns the size of he memory attachment + + """ + return self._data.getbuffer().nbytes + + def __bool__(self): + """ + Allows the Apprise object to be wrapped in an based 'if statement'. + True is returned if our content was downloaded correctly. + """ + + return self.exists() diff --git a/apprise/plugins/ntfy.py b/apprise/plugins/ntfy.py index 805b87260..4814c9aa5 100644 --- a/apprise/plugins/ntfy.py +++ b/apprise/plugins/ntfy.py @@ -53,6 +53,7 @@ from ..utils import validate_regex from ..url import PrivacyMode from ..attachment.base import AttachBase +from ..attachment.memory import AttachMemory class NtfyMode: @@ -172,6 +173,20 @@ class NotifyNtfy(NotifyBase): # Support attachments attachment_support = True + # Maximum title length + title_maxlen = 200 + + # Maximum body length + body_maxlen = 7800 + + # Message size calculates title and body together + overflow_amalgamate_title = True + + # Defines the number of bytes our JSON object can not exceed in size or we + # know the upstream server will reject it. We convert these into + # attachments + ntfy_json_upstream_size_limit = 8000 + # Allows the user to specify the NotifyImageSize object image_size = NotifyImageSize.XY_256 @@ -504,7 +519,7 @@ def _send(self, topic, body=None, title=None, attach=None, image_url=None, # Prepare our Header virt_payload['filename'] = attach.name - with open(attach.path, 'rb') as fp: + with attach as fp: data = fp.read() if image_url: @@ -538,18 +553,39 @@ def _send(self, topic, body=None, title=None, attach=None, image_url=None, self.logger.debug('ntfy POST URL: %s (cert_verify=%r)' % ( notify_url, self.verify_certificate, )) - self.logger.debug('ntfy Payload: %s' % str(virt_payload)) - self.logger.debug('ntfy Headers: %s' % str(headers)) - - # Always call throttle before any remote server i/o is made - self.throttle() # Default response type response = None if not attach: data = dumps(data) + if len(data) > self.ntfy_json_upstream_size_limit: + # Convert to an attachment + + if self.notify_format == NotifyFormat.MARKDOWN: + mimetype = 'text/markdown' + elif self.notify_format == NotifyFormat.TEXT: + mimetype = 'text/plain' + + else: # self.notify_format == NotifyFormat.HTML: + mimetype = 'text/html' + + attach = AttachMemory( + mimetype=mimetype, + content='{title}{body}'.format( + title=title + '\n' if title else '', body=body)) + + # Recursively send the message body as an attachment instead + return self._send( + topic=topic, body='', title='', attach=attach, + image_url=image_url, **kwargs) + + self.logger.debug('ntfy Payload: %s' % str(virt_payload)) + self.logger.debug('ntfy Headers: %s' % str(headers)) + + # Always call throttle before any remote server i/o is made + self.throttle() try: r = requests.post( notify_url, diff --git a/test/test_apprise_attachments.py b/test/test_apprise_attachments.py index 847531ad7..27813d379 100644 --- a/test/test_apprise_attachments.py +++ b/test/test_apprise_attachments.py @@ -284,9 +284,6 @@ def test_apprise_attachment_truncate(mock_get): # Add ourselves an object set to truncate ap_obj.add('json://localhost/?method=GET&overflow=truncate') - # Add ourselves a second object without truncate - ap_obj.add('json://localhost/?method=GET&overflow=upstream') - # Create ourselves an attachment object aa = AppriseAttachment() @@ -304,15 +301,36 @@ def test_apprise_attachment_truncate(mock_get): assert mock_get.call_count == 0 assert ap_obj.notify(body='body', title='title', attach=aa) - assert mock_get.call_count == 2 + assert mock_get.call_count == 1 # Our first item was truncated, so only 1 attachment details = mock_get.call_args_list[0] dataset = json.loads(details[1]['data']) assert len(dataset['attachments']) == 1 - # Our second item was not truncated, so all attachments - details = mock_get.call_args_list[1] + # Reset our object + mock_get.reset_mock() + + # our Apprise Object + ap_obj = Apprise() + + # Add ourselves an object set to upstream + ap_obj.add('json://localhost/?method=GET&overflow=upstream') + + # Create ourselves an attachment object + aa = AppriseAttachment() + + # Add 2 attachments + assert aa.add(join(TEST_VAR_DIR, 'apprise-test.gif')) + assert aa.add(join(TEST_VAR_DIR, 'apprise-test.png')) + + assert mock_get.call_count == 0 + assert ap_obj.notify(body='body', title='title', attach=aa) + + assert mock_get.call_count == 1 + + # Our item was not truncated, so all attachments + details = mock_get.call_args_list[0] dataset = json.loads(details[1]['data']) assert len(dataset['attachments']) == 2 diff --git a/test/test_attach_base.py b/test/test_attach_base.py index 8defd79bc..26b320554 100644 --- a/test/test_attach_base.py +++ b/test/test_attach_base.py @@ -73,15 +73,6 @@ def test_attach_base(): with pytest.raises(NotImplementedError): obj.download() - with pytest.raises(NotImplementedError): - obj.name - - with pytest.raises(NotImplementedError): - obj.path - - with pytest.raises(NotImplementedError): - obj.mimetype - # Unsupported URLs are not parsed assert AttachBase.parse_url(url='invalid://') is None diff --git a/test/test_attach_file.py b/test/test_attach_file.py index 4f4fadee7..d0734832d 100644 --- a/test/test_attach_file.py +++ b/test/test_attach_file.py @@ -204,8 +204,7 @@ def test_attach_file(): assert response.path == path assert response.name == 'test.jpeg' assert response.mimetype == 'image/jpeg' - # We will match on mime type now (%2F = /) - assert re.search(r'[?&]mime=image%2Fjpeg', response.url(), re.I) + assert re.search(r'[?&]mime=image/jpeg', response.url(), re.I) assert re.search(r'[?&]name=test\.jpeg', response.url(), re.I) # Test hosted configuration and that we can't add a valid file diff --git a/test/test_attach_http.py b/test/test_attach_http.py index 217ed78f9..ad58ed911 100644 --- a/test/test_attach_http.py +++ b/test/test_attach_http.py @@ -229,7 +229,7 @@ def __exit__(self, *args, **kwargs): attachment = AttachHTTP(**results) assert isinstance(attachment.url(), str) is True # both mime and name over-ridden - assert re.search(r'[?&]mime=image%2Fjpeg', attachment.url()) + assert re.search(r'[?&]mime=image/jpeg', attachment.url()) assert re.search(r'[?&]name=usethis.jpg', attachment.url()) # No Content-Disposition; so we use filename from path assert attachment.name == 'usethis.jpg' diff --git a/test/test_attach_memory.py b/test/test_attach_memory.py new file mode 100644 index 000000000..a4bec417f --- /dev/null +++ b/test/test_attach_memory.py @@ -0,0 +1,205 @@ +# -*- coding: utf-8 -*- +# BSD 2-Clause License +# +# Apprise - Push Notification Library. +# Copyright (c) 2024, Chris Caron +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# +# 1. Redistributions of source code must retain the above copyright notice, +# this list of conditions and the following disclaimer. +# +# 2. Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions and the following disclaimer in the documentation +# and/or other materials provided with the distribution. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE +# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +# POSSIBILITY OF SUCH DAMAGE. + +import re +import urllib +import pytest + +from apprise.attachment.base import AttachBase +from apprise.attachment.memory import AttachMemory +from apprise import AppriseAttachment +from apprise.common import ContentLocation + +# Disable logging for a cleaner testing output +import logging +logging.disable(logging.CRITICAL) + + +def test_attach_memory_parse_url(): + """ + API: AttachMemory().parse_url() + + """ + + # Bad Entry + assert AttachMemory.parse_url(object) is None + + # Our filename is detected automatically + assert AttachMemory.parse_url('memory://') + + # pass our content in as a string + mem = AttachMemory(content='string') + # it loads a string type by default + mem.mimetype == 'text/plain' + # Our filename is automatically generated (with .txt) + assert re.match(r'^[a-z0-9-]+\.txt$', mem.name, re.I) + + # open our file + with mem as fp: + assert fp.getbuffer().nbytes == len(mem) + + # pass our content in as a string + mem = AttachMemory( + content='', name='test.html', mimetype='text/html') + # it loads a string type by default + mem.mimetype == 'text/html' + mem.name == 'test.html' + + # Stub function + assert mem.download() + + with pytest.raises(TypeError): + # garbage in, garbage out + AttachMemory(content=3) + + # pointer to our data + pointer = mem.open() + assert pointer.read() == b'' + + # pass our content in as a string + mem = AttachMemory(content=b'binary-data', name='raw.dat') + # it loads a string type by default + assert mem.mimetype == 'application/octet-stream' + mem.name == 'raw' + + # pass our content in as a string + mem = AttachMemory(content=b'binary-data') + # it loads a string type by default + assert mem.mimetype == 'application/octet-stream' + # Our filename is automatically generated (with .dat) + assert re.match(r'^[a-z0-9-]+\.dat$', mem.name, re.I) + + +def test_attach_memory(): + """ + API: AttachMemory() + + """ + # A url we can test with + fname = 'testfile' + url = 'memory:///ignored/path/{fname}'.format(fname=fname) + + # Simple gif test + response = AppriseAttachment.instantiate(url) + assert isinstance(response, AttachMemory) + + # There is no path yet as we haven't written anything to our memory object + # yet + assert response.path is None + assert bool(response) is False + + with response as memobj: + memobj.write(b'content') + + # Memory object defaults + assert response.name == fname + assert response.path == response.name + assert response.mimetype == 'application/octet-stream' + assert bool(response) is True + + # + fname_in_url = urllib.parse.quote(response.name) + assert response.url().startswith('memory://{}'.format(fname_in_url)) + + # Mime is always part of url + assert re.search(r'[?&]mime=', response.url()) is not None + + # Test case where location is simply set to INACCESSIBLE + # Below is a bad example, but it proves the section of code properly works. + # Ideally a server admin may wish to just disable all File based + # attachments entirely. In this case, they simply just need to change the + # global singleton at the start of their program like: + # + # import apprise + # apprise.attachment.AttachMemory.location = \ + # apprise.ContentLocation.INACCESSIBLE + # + response = AppriseAttachment.instantiate(url) + assert isinstance(response, AttachMemory) + with response as memobj: + memobj.write(b'content') + + response.location = ContentLocation.INACCESSIBLE + assert response.path is None + # Downloads just don't work period + assert response.download() is False + + # File handling (even if image is set to maxium allowable) + response = AppriseAttachment.instantiate(url) + assert isinstance(response, AttachMemory) + with response as memobj: + memobj.write(b'content') + + # Memory handling when size is to large + response = AppriseAttachment.instantiate(url) + assert isinstance(response, AttachMemory) + with response as memobj: + memobj.write(b'content') + + # Test case where we exceed our defined max_file_size in memory + prev_value = AttachBase.max_file_size + AttachBase.max_file_size = len(response) - 1 + # We can't work in this case + assert response.path is None + assert response.download() is False + + # Restore our file_size + AttachBase.max_file_size = prev_value + + response = AppriseAttachment.instantiate( + 'memory://apprise-file.gif?mime=image/gif') + assert isinstance(response, AttachMemory) + with response as memobj: + memobj.write(b'content') + + assert response.name == 'apprise-file.gif' + assert response.path == response.name + assert response.mimetype == 'image/gif' + # No mime-type and/or filename over-ride was specified, so therefore it + # won't show up in the generated URL + assert re.search(r'[?&]mime=', response.url()) is not None + assert 'image/gif' in response.url() + + # Force a mime-type and new name + response = AppriseAttachment.instantiate( + 'memory://{}?mime={}&name={}'.format( + 'ignored.gif', 'image/jpeg', 'test.jpeg')) + assert isinstance(response, AttachMemory) + with response as memobj: + memobj.write(b'content') + + assert response.name == 'test.jpeg' + assert response.path == response.name + assert response.mimetype == 'image/jpeg' + # We will match on mime type now (%2F = /) + assert re.search(r'[?&]mime=image/jpeg', response.url(), re.I) + assert response.url().startswith('memory://test.jpeg') + + # Test hosted configuration and that we can't add a valid memory file + aa = AppriseAttachment(location=ContentLocation.HOSTED) + assert aa.add(response) is False diff --git a/test/test_plugin_ntfy.py b/test/test_plugin_ntfy.py index e976a29c0..30d0eacb0 100644 --- a/test/test_plugin_ntfy.py +++ b/test/test_plugin_ntfy.py @@ -26,6 +26,7 @@ # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. +import re import os import json from unittest import mock @@ -571,3 +572,47 @@ def test_plugin_ntfy_config_files(mock_post, mock_get): assert len([x for x in aobj.find(tag='ntfy_invalid')]) == 1 assert next(aobj.find(tag='ntfy_invalid')).priority == \ NtfyPriority.NORMAL + + +@mock.patch('requests.post') +def test_plugin_ntfy_message_to_attach(mock_post): + """ + NotifyNtfy() large messages converted into attachments + + """ + + # Prepare Mock return object + response = mock.Mock() + response.content = GOOD_RESPONSE_TEXT + response.status_code = requests.codes.ok + mock_post.return_value = response + + # Create a very, very big message + title = 'My Title' + body = 'b' * NotifyNtfy.ntfy_json_upstream_size_limit + + for fmt in apprise.NOTIFY_FORMATS: + + # Prepare our object + obj = apprise.Apprise.instantiate( + 'ntfy://user:pass@localhost:8080/topic?format={}'.format(fmt)) + + # Our content will actually transfer as an attachment + assert obj.notify(title=title, body=body) + assert mock_post.call_count == 1 + + assert mock_post.call_args_list[0][0][0] == \ + 'http://localhost:8080/topic' + + response = mock_post.call_args_list[0][1] + assert 'data' in response + assert response['data'].decode('utf-8').startswith(title) + assert response['data'].decode('utf-8').endswith(body) + assert 'params' in response + assert 'filename' in response['params'] + # Our filename is automatically generated (with .txt) + assert re.match( + r'^[a-z0-9-]+\.txt$', response['params']['filename'], re.I) + + # Reset our mock object + mock_post.reset_mock()