From 784e073eea64d2ee37cc52e7a2391bce35b05720 Mon Sep 17 00:00:00 2001 From: Chris Caron Date: Sun, 13 Sep 2020 23:06:41 -0400 Subject: [PATCH] Discord markdown enhancements (#295) --- apprise/plugins/NotifyDiscord.py | 84 +++++++++++++++++++++++------- test/test_discord_plugin.py | 89 +++++++++++++++++++++++++++++--- test/test_rest_plugins.py | 2 +- 3 files changed, 150 insertions(+), 25 deletions(-) diff --git a/apprise/plugins/NotifyDiscord.py b/apprise/plugins/NotifyDiscord.py index 8a8b21f4..0bdc27a6 100644 --- a/apprise/plugins/NotifyDiscord.py +++ b/apprise/plugins/NotifyDiscord.py @@ -80,6 +80,11 @@ class NotifyDiscord(NotifyBase): # The maximum allowable characters allowed in the body per message body_maxlen = 2000 + # Discord has a limit of the number of fields you can include in an + # embeds message. This value allows the discord message to safely + # break into multiple messages to handle these cases. + discord_max_fields = 10 + # Define object templates templates = ( '{schema}://{webhook_id}/{webhook_token}', @@ -133,6 +138,11 @@ class NotifyDiscord(NotifyBase): 'type': 'bool', 'default': True, }, + 'fields': { + 'name': _('Use Fields'), + 'type': 'bool', + 'default': True, + }, 'image': { 'name': _('Include Image'), 'type': 'bool', @@ -143,7 +153,7 @@ class NotifyDiscord(NotifyBase): def __init__(self, webhook_id, webhook_token, tts=False, avatar=True, footer=False, footer_logo=True, include_image=False, - avatar_url=None, **kwargs): + fields=True, avatar_url=None, **kwargs): """ Initialize Discord Object @@ -181,6 +191,9 @@ class NotifyDiscord(NotifyBase): # Place a thumbnail image inline with the message body self.include_image = include_image + # Use Fields + self.fields = fields + # Avatar URL # This allows a user to provide an over-ride to the otherwise # dynamically generated avatar url images @@ -206,32 +219,23 @@ class NotifyDiscord(NotifyBase): # Acquire image_url image_url = self.image_url(notify_type) + # our fields variable + fields = [] + if self.notify_format == NotifyFormat.MARKDOWN: # Use embeds for payload payload['embeds'] = [{ - 'provider': { + 'author': { 'name': self.app_id, 'url': self.app_url, }, 'title': title, - 'type': 'rich', 'description': body, # Our color associated with our notification 'color': self.color(notify_type, int), }] - # Break titles out so that we can sort them in embeds - fields = self.extract_markdown_sections(body) - - if len(fields) > 0: - # Apply our additional parsing for a better presentation - - # Swap first entry for description - payload['embeds'][0]['description'] = \ - fields[0].get('name') + fields[0].get('value') - payload['embeds'][0]['fields'] = fields[1:] - if self.footer: # Acquire logo URL logo_url = self.image_url(notify_type, logo=True) @@ -251,6 +255,20 @@ class NotifyDiscord(NotifyBase): 'width': 256, } + if self.fields: + # Break titles out so that we can sort them in embeds + description, fields = self.extract_markdown_sections(body) + + # Swap first entry for description + payload['embeds'][0]['description'] = description + if fields: + # Apply our additional parsing for a better presentation + payload['embeds'][0]['fields'] = \ + fields[:self.discord_max_fields] + + # Remove entry from head of fields + fields = fields[self.discord_max_fields:] + else: # not markdown payload['content'] = \ @@ -268,6 +286,16 @@ class NotifyDiscord(NotifyBase): # We failed to post our message return False + # Process any remaining fields IF set + if fields: + payload['embeds'][0]['description'] = '' + for i in range(0, len(fields), self.discord_max_fields): + payload['embeds'][0]['fields'] = \ + fields[i:i + self.discord_max_fields] + 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 @@ -413,6 +441,7 @@ class NotifyDiscord(NotifyBase): 'footer': 'yes' if self.footer else 'no', 'footer_logo': 'yes' if self.footer_logo else 'no', 'image': 'yes' if self.include_image else 'no', + 'fields': 'yes' if self.fields else 'no', } # Extend our parameters @@ -459,6 +488,11 @@ class NotifyDiscord(NotifyBase): # Text To Speech results['tts'] = parse_bool(results['qsd'].get('tts', False)) + # Use sections + # effectively detect multiple fields and break them off + # into sections + results['fields'] = parse_bool(results['qsd'].get('fields', True)) + # Use Footer results['footer'] = parse_bool(results['qsd'].get('footer', False)) @@ -513,6 +547,18 @@ class NotifyDiscord(NotifyBase): fields that get passed as an embed entry to Discord. """ + # Search for any header information found without it's own section + # identifier + match = re.match( + r'^\s*(?P[^\s#]+.*?)(?=\s*$|[\r\n]+\s*#)', + markdown, flags=re.S) + + description = match.group('desc').strip() if match else '' + if description: + # Strip description from our string since it has been handled + # now. + markdown = re.sub(description, '', markdown, count=1) + regex = re.compile( r'\s*#[# \t\v]*(?P[^\n]+)(\n|\s*$)' r'\s*((?P[^#].+?)(?=\s*$|[\r\n]+\s*#))?', flags=re.S) @@ -523,9 +569,11 @@ class NotifyDiscord(NotifyBase): d = el.groupdict() fields.append({ - 'name': d.get('name', '').strip('# \r\n\t\v'), - 'value': '```md\n' + - (d.get('value').strip() if d.get('value') else '') + '\n```' + 'name': d.get('name', '').strip('#`* \r\n\t\v'), + 'value': '```{}\n{}```'.format( + 'md' if d.get('value') else '', + d.get('value').strip() + '\n' if d.get('value') else '', + ), }) - return fields + return description, fields diff --git a/test/test_discord_plugin.py b/test/test_discord_plugin.py index 7cb5ea97..ea884fc3 100644 --- a/test/test_discord_plugin.py +++ b/test/test_discord_plugin.py @@ -84,6 +84,41 @@ def test_discord_plugin(mock_post): assert obj.notify( body='body', title='title', notify_type=NotifyType.INFO) is True + # Simple Markdown Single line of text + test_markdown = "body" + desc, results = obj.extract_markdown_sections(test_markdown) + assert isinstance(results, list) is True + assert len(results) == 0 + + # Test our header parsing when not lead with a header + test_markdown = """ + A section of text that has no header at the top. + It also has a hash tag # <- in the middle of a + string. + + ## Heading 1 + body + + # Heading 2 + + more content + on multi-lines + """ + + desc, results = obj.extract_markdown_sections(test_markdown) + # we have a description + assert isinstance(desc, six.string_types) is True + assert desc.startswith('A section of text that has no header at the top.') + assert desc.endswith('string.') + + assert isinstance(results, list) is True + assert len(results) == 2 + assert results[0]['name'] == 'Heading 1' + assert results[0]['value'] == '```md\nbody\n```' + assert results[1]['name'] == 'Heading 2' + assert results[1]['value'] == \ + '```md\nmore content\n on multi-lines\n```' + # Test our header parsing test_markdown = "## Heading one\nbody body\n\n" + \ "# Heading 2 ##\n\nTest\n\n" + \ @@ -94,8 +129,12 @@ def test_discord_plugin(mock_post): "# heading 4\n" + \ "#### Heading 5" - results = obj.extract_markdown_sections(test_markdown) + desc, results = obj.extract_markdown_sections(test_markdown) assert isinstance(results, list) is True + # No desc details filled out + assert isinstance(desc, six.string_types) is True + assert not desc + # We should have 5 sections (since there are 5 headers identified above) assert len(results) == 5 assert results[0]['name'] == 'Heading one' @@ -107,29 +146,67 @@ def test_discord_plugin(mock_post): assert results[2]['value'] == \ '```md\nnormal content\n```' assert results[3]['name'] == 'heading 4' - assert results[3]['value'] == '```md\n\n```' + assert results[3]['value'] == '```\n```' assert results[4]['name'] == 'Heading 5' - assert results[4]['value'] == '```md\n\n```' + assert results[4]['value'] == '```\n```' + + # 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: + plugins.NotifyDiscord.discord_max_fields = 1 + + assert a.notify(body=test_markdown, title='title', + notify_type=NotifyType.INFO, + body_format=NotifyFormat.TEXT) is True + + # Throw an exception on the forth call to requests.post() + # This allows to test our batch field processing + response = mock.Mock() + response.content = '' + response.status_code = requests.codes.ok + mock_post.return_value = response + mock_post.side_effect = [ + response, response, response, requests.RequestException()] # 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 + body=test_markdown, title='title', + notify_type=NotifyType.INFO) is False + mock_post.side_effect = None # Empty String - results = obj.extract_markdown_sections("") + desc, results = obj.extract_markdown_sections("") assert isinstance(results, list) is True assert len(results) == 0 + # No desc details filled out + assert isinstance(desc, six.string_types) is True + assert not desc + # String without Heading test_markdown = "Just a string without any header entries.\n" + \ "A second line" - results = obj.extract_markdown_sections(test_markdown) + desc, results = obj.extract_markdown_sections(test_markdown) assert isinstance(results, list) is True assert len(results) == 0 + # No desc details filled out + assert isinstance(desc, six.string_types) is True + assert desc == 'Just a string without any header entries.\n' + \ + 'A second line' + # Use our test markdown string during a notification assert obj.notify( body=test_markdown, title='title', notify_type=NotifyType.INFO) is True diff --git a/test/test_rest_plugins.py b/test/test_rest_plugins.py index c38f5f8e..af835905 100644 --- a/test/test_rest_plugins.py +++ b/test/test_rest_plugins.py @@ -289,7 +289,7 @@ TEST_URLS = ( # don't include an image by default 'include_image': False, }), - ('discord://%s/%s?format=markdown&footer=Yes&image=No' % ( + ('discord://%s/%s?format=markdown&footer=Yes&image=No&fields=no' % ( 'i' * 24, 't' * 64), { 'instance': plugins.NotifyDiscord, 'requests_response_code': requests.codes.no_content,