diff --git a/apprise/plugins/NotifyDiscord.py b/apprise/plugins/NotifyDiscord.py index 7394ddc4..72e7ac4c 100644 --- a/apprise/plugins/NotifyDiscord.py +++ b/apprise/plugins/NotifyDiscord.py @@ -42,7 +42,6 @@ # import re import requests -from json import dumps from .NotifyBase import NotifyBase from ..common import NotifyImageSize @@ -178,17 +177,12 @@ class NotifyDiscord(NotifyBase): return - def send(self, body, title='', notify_type=NotifyType.INFO, **kwargs): + def send(self, body, title='', notify_type=NotifyType.INFO, attach=None, + **kwargs): """ Perform Discord Notification """ - headers = { - 'User-Agent': self.app_id, - 'Content-Type': 'multipart/form-data', - } - - # Prepare JSON Object payload = { # Text-To-Speech 'tts': self.tts, @@ -258,6 +252,50 @@ class NotifyDiscord(NotifyBase): # Optionally override the default username of the webhook payload['username'] = self.user + if not self._send(payload): + # We failed to post our message + return False + + if attach: + # Update our payload; the idea is to preserve it's other detected + # and assigned values for re-use here too + payload.update({ + # Text-To-Speech + 'tts': False, + # Wait until the upload has posted itself before continuing + 'wait': True, + }) + + # Remove our text/title based content for attachment use + if 'embeds' in payload: + # Markdown + del payload['embeds'] + + if 'content' in payload: + # Markdown + del payload['content'] + + # Send our attachments + for attachment in attach: + self.logger.info( + 'Posting Discord Attachment {}'.format(attachment.name)) + if not self._send(payload, attach=attachment): + # We failed to post our message + return False + + # Otherwise return + return True + + def _send(self, payload, attach=None, **kwargs): + """ + Wrapper to the requests (post) object + """ + + # Our headers + headers = { + 'User-Agent': self.app_id, + } + # Construct Notify URL notify_url = '{0}/{1}/{2}'.format( self.notify_url, @@ -273,11 +311,19 @@ class NotifyDiscord(NotifyBase): # Always call throttle before any remote server i/o is made self.throttle() + # Our attachment path (if specified) + files = None try: + + # Open our attachment path if required: + if attach: + files = (attach.name, open(attach.path, 'rb')) + r = requests.post( notify_url, - data=dumps(payload), + data=payload, headers=headers, + files=files, verify=self.verify_certificate, ) if r.status_code not in ( @@ -288,8 +334,9 @@ class NotifyDiscord(NotifyBase): NotifyBase.http_response_code_lookup(r.status_code) self.logger.warning( - 'Failed to send Discord notification: ' + 'Failed to send {}to Discord notification: ' '{}{}error={}.'.format( + attach.name if attach else '', status_str, ', ' if status_str else '', r.status_code)) @@ -304,12 +351,24 @@ class NotifyDiscord(NotifyBase): except requests.RequestException as e: self.logger.warning( - 'A Connection error occured sending Discord ' - 'notification.' - ) + 'A Connection error occured posting {}to Discord.'.format( + attach.name if attach else '')) self.logger.debug('Socket Exception: %s' % str(e)) return False + except (OSError, IOError) as e: + self.logger.warning( + 'An I/O error occured while reading {}.'.format( + attach.name if attach else 'attachment')) + self.logger.debug('I/O Exception: %s' % str(e)) + return False + + finally: + # Close our file (if it's open) stored in the second element + # of our files tuple (index 1) + if files: + files[1].close() + return True def url(self, privacy=False, *args, **kwargs): diff --git a/test/test_discord_plugin.py b/test/test_discord_plugin.py new file mode 100644 index 00000000..a29e0bec --- /dev/null +++ b/test/test_discord_plugin.py @@ -0,0 +1,191 @@ +# -*- coding: utf-8 -*- +# +# All rights reserved. +# +# This code is licensed under the MIT License. +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files(the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and / or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions : +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. + +import os +import six +import mock +import pytest +import requests +from apprise import Apprise +from apprise import AppriseAttachment +from apprise import plugins +from apprise import NotifyType +from apprise import NotifyFormat + +# Disable logging for a cleaner testing output +import logging +logging.disable(logging.CRITICAL) + +# Attachment Directory +TEST_VAR_DIR = os.path.join(os.path.dirname(__file__), 'var') + + +@mock.patch('requests.post') +def test_discord_plugin(mock_post): + """ + API: NotifyDiscord() General Checks + + """ + # Disable Throttling to speed testing + plugins.NotifyBase.request_rate_per_sec = 0 + + # Initialize some generic (but valid) tokens + webhook_id = 'A' * 24 + webhook_token = 'B' * 64 + + # Prepare Mock + mock_post.return_value = requests.Request() + mock_post.return_value.status_code = requests.codes.ok + + # Invalid webhook id + with pytest.raises(TypeError): + plugins.NotifyDiscord(webhook_id=None, webhook_token=webhook_token) + # Invalid webhook id (whitespace) + with pytest.raises(TypeError): + plugins.NotifyDiscord(webhook_id=" ", webhook_token=webhook_token) + + # Invalid webhook token + with pytest.raises(TypeError): + plugins.NotifyDiscord(webhook_id=webhook_id, webhook_token=None) + # Invalid webhook token (whitespace) + with pytest.raises(TypeError): + plugins.NotifyDiscord(webhook_id=webhook_id, webhook_token=" ") + + obj = plugins.NotifyDiscord( + webhook_id=webhook_id, + webhook_token=webhook_token, + footer=True, thumbnail=False) + + # Test that we get a string response + assert isinstance(obj.url(), six.string_types) is True + + # This call includes an image with it's payload: + assert obj.notify( + body='body', title='title', notify_type=NotifyType.INFO) is True + + # Test our header parsing + test_markdown = "## Heading one\nbody body\n\n" + \ + "# Heading 2 ##\n\nTest\n\n" + \ + "more content\n" + \ + "even more content \t\r\n\n\n" + \ + "# Heading 3 ##\n\n\n" + \ + "normal content\n" + \ + "# heading 4\n" + \ + "#### Heading 5" + + results = obj.extract_markdown_sections(test_markdown) + assert isinstance(results, list) is True + # We should have 5 sections (since there are 5 headers identified above) + assert len(results) == 5 + assert results[0]['name'] == 'Heading one' + assert results[0]['value'] == '```md\nbody body\n```' + assert results[1]['name'] == 'Heading 2' + assert results[1]['value'] == \ + '```md\nTest\n\nmore content\neven more content\n```' + assert results[2]['name'] == 'Heading 3' + assert results[2]['value'] == \ + '```md\nnormal content\n```' + assert results[3]['name'] == 'heading 4' + assert results[3]['value'] == '```md\n\n```' + assert results[4]['name'] == 'Heading 5' + assert results[4]['value'] == '```md\n\n```' + + # Test our markdown + obj = Apprise.instantiate( + 'discord://{}/{}/?format=markdown'.format(webhook_id, webhook_token)) + assert isinstance(obj, plugins.NotifyDiscord) + assert obj.notify( + body=test_markdown, title='title', notify_type=NotifyType.INFO) is True + + # Empty String + results = obj.extract_markdown_sections("") + assert isinstance(results, list) is True + assert len(results) == 0 + + # String without Heading + test_markdown = "Just a string without any header entries.\n" + \ + "A second line" + results = obj.extract_markdown_sections(test_markdown) + assert isinstance(results, list) is True + assert len(results) == 0 + + # Use our test markdown string during a notification + assert obj.notify( + body=test_markdown, title='title', notify_type=NotifyType.INFO) is True + + # Create an apprise instance + a = Apprise() + + # Our processing is slightly different when we aren't using markdown + # as we do not pre-parse content during our notifications + assert a.add( + 'discord://{webhook_id}/{webhook_token}/' + '?format=markdown&footer=Yes'.format( + webhook_id=webhook_id, + webhook_token=webhook_token)) is True + + # This call includes an image with it's payload: + assert a.notify(body=test_markdown, title='title', + notify_type=NotifyType.INFO, + body_format=NotifyFormat.TEXT) is True + + assert a.notify(body=test_markdown, title='title', + notify_type=NotifyType.INFO, + body_format=NotifyFormat.MARKDOWN) is True + + # Toggle our logo availability + a.asset.image_url_logo = None + assert a.notify( + body='body', title='title', notify_type=NotifyType.INFO) is True + + +@mock.patch('requests.post') +def test_discord_attachments(mock_post): + """ + API: NotifyDiscord() Attachment Checks + + """ + # Disable Throttling to speed testing + plugins.NotifyBase.request_rate_per_sec = 0 + + # Initialize some generic (but valid) tokens + webhook_id = 'C' * 24 + webhook_token = 'D' * 64 + + # Prepare Mock return object + response = mock.Mock() + response.status_code = requests.codes.ok + + # Throw an exception on the second call to requests.post() + mock_post.side_effect = [response, OSError()] + + # Test our markdown + obj = Apprise.instantiate( + 'discord://{}/{}/?format=markdown'.format(webhook_id, webhook_token)) + + # attach our content + attach = AppriseAttachment(os.path.join(TEST_VAR_DIR, 'apprise-test.gif')) + + # We'll fail now because of an internal exception + assert obj.send(body="test", attach=attach) is False diff --git a/test/test_rest_plugins.py b/test/test_rest_plugins.py index e812f02d..410d7cc0 100644 --- a/test/test_rest_plugins.py +++ b/test/test_rest_plugins.py @@ -3849,125 +3849,6 @@ def test_notify_boxcar_plugin(mock_post, mock_get): assert len(p.device_tokens) == 3 -@mock.patch('requests.get') -@mock.patch('requests.post') -def test_notify_discord_plugin(mock_post, mock_get): - """ - API: NotifyDiscord() Extra Checks - - """ - # Disable Throttling to speed testing - plugins.NotifyBase.request_rate_per_sec = 0 - - # Initialize some generic (but valid) tokens - webhook_id = 'A' * 24 - webhook_token = 'B' * 64 - - # Prepare Mock - mock_get.return_value = requests.Request() - mock_post.return_value = requests.Request() - mock_post.return_value.status_code = requests.codes.ok - mock_get.return_value.status_code = requests.codes.ok - - # Invalid webhook id - with pytest.raises(TypeError): - plugins.NotifyDiscord(webhook_id=None, webhook_token=webhook_token) - # Invalid webhook id (whitespace) - with pytest.raises(TypeError): - plugins.NotifyDiscord(webhook_id=" ", webhook_token=webhook_token) - - # Invalid webhook token - with pytest.raises(TypeError): - plugins.NotifyDiscord(webhook_id=webhook_id, webhook_token=None) - # Invalid webhook token (whitespace) - with pytest.raises(TypeError): - plugins.NotifyDiscord(webhook_id=webhook_id, webhook_token=" ") - - obj = plugins.NotifyDiscord( - webhook_id=webhook_id, - webhook_token=webhook_token, - footer=True, thumbnail=False) - - # This call includes an image with it's payload: - assert obj.notify( - body='body', title='title', notify_type=NotifyType.INFO) is True - - # Test our header parsing - test_markdown = "## Heading one\nbody body\n\n" + \ - "# Heading 2 ##\n\nTest\n\n" + \ - "more content\n" + \ - "even more content \t\r\n\n\n" + \ - "# Heading 3 ##\n\n\n" + \ - "normal content\n" + \ - "# heading 4\n" + \ - "#### Heading 5" - - results = obj.extract_markdown_sections(test_markdown) - assert isinstance(results, list) is True - # We should have 5 sections (since there are 5 headers identified above) - assert len(results) == 5 - assert results[0]['name'] == 'Heading one' - assert results[0]['value'] == '```md\nbody body\n```' - assert results[1]['name'] == 'Heading 2' - assert results[1]['value'] == \ - '```md\nTest\n\nmore content\neven more content\n```' - assert results[2]['name'] == 'Heading 3' - assert results[2]['value'] == \ - '```md\nnormal content\n```' - assert results[3]['name'] == 'heading 4' - assert results[3]['value'] == '```md\n\n```' - assert results[4]['name'] == 'Heading 5' - assert results[4]['value'] == '```md\n\n```' - - # Test our markdown - obj = Apprise.instantiate( - 'discord://{}/{}/?format=markdown'.format(webhook_id, webhook_token)) - assert isinstance(obj, plugins.NotifyDiscord) - assert obj.notify( - body=test_markdown, title='title', notify_type=NotifyType.INFO) is True - - # Empty String - results = obj.extract_markdown_sections("") - assert isinstance(results, list) is True - assert len(results) == 0 - - # String without Heading - test_markdown = "Just a string without any header entries.\n" + \ - "A second line" - results = obj.extract_markdown_sections(test_markdown) - assert isinstance(results, list) is True - assert len(results) == 0 - - # Use our test markdown string during a notification - assert obj.notify( - body=test_markdown, title='title', notify_type=NotifyType.INFO) is True - - # Create an apprise instance - a = Apprise() - - # Our processing is slightly different when we aren't using markdown - # as we do not pre-parse content during our notifications - assert a.add( - 'discord://{webhook_id}/{webhook_token}/' - '?format=markdown&footer=Yes'.format( - webhook_id=webhook_id, - webhook_token=webhook_token)) is True - - # This call includes an image with it's payload: - assert a.notify(body=test_markdown, title='title', - notify_type=NotifyType.INFO, - body_format=NotifyFormat.TEXT) is True - - assert a.notify(body=test_markdown, title='title', - notify_type=NotifyType.INFO, - body_format=NotifyFormat.MARKDOWN) is True - - # Toggle our logo availability - a.asset.image_url_logo = None - assert a.notify( - body='body', title='title', notify_type=NotifyType.INFO) is True - - @mock.patch('requests.get') @mock.patch('requests.post') def test_notify_emby_plugin_login(mock_post, mock_get):