Discord markdown enhancements (#295)

pull/297/head
Chris Caron 2020-09-13 23:06:41 -04:00 committed by GitHub
parent 49faa9a201
commit 784e073eea
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
3 changed files with 150 additions and 25 deletions

View File

@ -80,6 +80,11 @@ class NotifyDiscord(NotifyBase):
# The maximum allowable characters allowed in the body per message # The maximum allowable characters allowed in the body per message
body_maxlen = 2000 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 # Define object templates
templates = ( templates = (
'{schema}://{webhook_id}/{webhook_token}', '{schema}://{webhook_id}/{webhook_token}',
@ -133,6 +138,11 @@ class NotifyDiscord(NotifyBase):
'type': 'bool', 'type': 'bool',
'default': True, 'default': True,
}, },
'fields': {
'name': _('Use Fields'),
'type': 'bool',
'default': True,
},
'image': { 'image': {
'name': _('Include Image'), 'name': _('Include Image'),
'type': 'bool', 'type': 'bool',
@ -143,7 +153,7 @@ class NotifyDiscord(NotifyBase):
def __init__(self, webhook_id, webhook_token, tts=False, avatar=True, def __init__(self, webhook_id, webhook_token, tts=False, avatar=True,
footer=False, footer_logo=True, include_image=False, footer=False, footer_logo=True, include_image=False,
avatar_url=None, **kwargs): fields=True, avatar_url=None, **kwargs):
""" """
Initialize Discord Object Initialize Discord Object
@ -181,6 +191,9 @@ class NotifyDiscord(NotifyBase):
# Place a thumbnail image inline with the message body # Place a thumbnail image inline with the message body
self.include_image = include_image self.include_image = include_image
# Use Fields
self.fields = fields
# Avatar URL # Avatar URL
# This allows a user to provide an over-ride to the otherwise # This allows a user to provide an over-ride to the otherwise
# dynamically generated avatar url images # dynamically generated avatar url images
@ -206,32 +219,23 @@ class NotifyDiscord(NotifyBase):
# Acquire image_url # Acquire image_url
image_url = self.image_url(notify_type) image_url = self.image_url(notify_type)
# our fields variable
fields = []
if self.notify_format == NotifyFormat.MARKDOWN: if self.notify_format == NotifyFormat.MARKDOWN:
# Use embeds for payload # Use embeds for payload
payload['embeds'] = [{ payload['embeds'] = [{
'provider': { 'author': {
'name': self.app_id, 'name': self.app_id,
'url': self.app_url, 'url': self.app_url,
}, },
'title': title, 'title': title,
'type': 'rich',
'description': body, 'description': body,
# Our color associated with our notification # Our color associated with our notification
'color': self.color(notify_type, int), '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: if self.footer:
# Acquire logo URL # Acquire logo URL
logo_url = self.image_url(notify_type, logo=True) logo_url = self.image_url(notify_type, logo=True)
@ -251,6 +255,20 @@ class NotifyDiscord(NotifyBase):
'width': 256, '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: else:
# not markdown # not markdown
payload['content'] = \ payload['content'] = \
@ -268,6 +286,16 @@ class NotifyDiscord(NotifyBase):
# We failed to post our message # We failed to post our message
return False 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: if attach:
# Update our payload; the idea is to preserve it's other detected # Update our payload; the idea is to preserve it's other detected
# and assigned values for re-use here too # and assigned values for re-use here too
@ -413,6 +441,7 @@ class NotifyDiscord(NotifyBase):
'footer': 'yes' if self.footer else 'no', 'footer': 'yes' if self.footer else 'no',
'footer_logo': 'yes' if self.footer_logo else 'no', 'footer_logo': 'yes' if self.footer_logo else 'no',
'image': 'yes' if self.include_image else 'no', 'image': 'yes' if self.include_image else 'no',
'fields': 'yes' if self.fields else 'no',
} }
# Extend our parameters # Extend our parameters
@ -459,6 +488,11 @@ class NotifyDiscord(NotifyBase):
# Text To Speech # Text To Speech
results['tts'] = parse_bool(results['qsd'].get('tts', False)) 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 # Use Footer
results['footer'] = parse_bool(results['qsd'].get('footer', False)) 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. 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<desc>[^\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( regex = re.compile(
r'\s*#[# \t\v]*(?P<name>[^\n]+)(\n|\s*$)' r'\s*#[# \t\v]*(?P<name>[^\n]+)(\n|\s*$)'
r'\s*((?P<value>[^#].+?)(?=\s*$|[\r\n]+\s*#))?', flags=re.S) r'\s*((?P<value>[^#].+?)(?=\s*$|[\r\n]+\s*#))?', flags=re.S)
@ -523,9 +569,11 @@ class NotifyDiscord(NotifyBase):
d = el.groupdict() d = el.groupdict()
fields.append({ fields.append({
'name': d.get('name', '').strip('# \r\n\t\v'), 'name': d.get('name', '').strip('#`* \r\n\t\v'),
'value': '```md\n' + 'value': '```{}\n{}```'.format(
(d.get('value').strip() if d.get('value') else '') + '\n```' 'md' if d.get('value') else '',
d.get('value').strip() + '\n' if d.get('value') else '',
),
}) })
return fields return description, fields

View File

@ -84,6 +84,41 @@ def test_discord_plugin(mock_post):
assert obj.notify( assert obj.notify(
body='body', title='title', notify_type=NotifyType.INFO) is True 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 our header parsing
test_markdown = "## Heading one\nbody body\n\n" + \ test_markdown = "## Heading one\nbody body\n\n" + \
"# Heading 2 ##\n\nTest\n\n" + \ "# Heading 2 ##\n\nTest\n\n" + \
@ -94,8 +129,12 @@ def test_discord_plugin(mock_post):
"# heading 4\n" + \ "# heading 4\n" + \
"#### Heading 5" "#### Heading 5"
results = obj.extract_markdown_sections(test_markdown) desc, results = obj.extract_markdown_sections(test_markdown)
assert isinstance(results, list) is True 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) # We should have 5 sections (since there are 5 headers identified above)
assert len(results) == 5 assert len(results) == 5
assert results[0]['name'] == 'Heading one' assert results[0]['name'] == 'Heading one'
@ -107,29 +146,67 @@ def test_discord_plugin(mock_post):
assert results[2]['value'] == \ assert results[2]['value'] == \
'```md\nnormal content\n```' '```md\nnormal content\n```'
assert results[3]['name'] == 'heading 4' 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]['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 # Test our markdown
obj = Apprise.instantiate( obj = Apprise.instantiate(
'discord://{}/{}/?format=markdown'.format(webhook_id, webhook_token)) 'discord://{}/{}/?format=markdown'.format(webhook_id, webhook_token))
assert isinstance(obj, plugins.NotifyDiscord) assert isinstance(obj, plugins.NotifyDiscord)
assert obj.notify( 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 # Empty String
results = obj.extract_markdown_sections("") desc, results = obj.extract_markdown_sections("")
assert isinstance(results, list) is True assert isinstance(results, list) is True
assert len(results) == 0 assert len(results) == 0
# No desc details filled out
assert isinstance(desc, six.string_types) is True
assert not desc
# String without Heading # String without Heading
test_markdown = "Just a string without any header entries.\n" + \ test_markdown = "Just a string without any header entries.\n" + \
"A second line" "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 isinstance(results, list) is True
assert len(results) == 0 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 # Use our test markdown string during a notification
assert obj.notify( assert obj.notify(
body=test_markdown, title='title', notify_type=NotifyType.INFO) is True body=test_markdown, title='title', notify_type=NotifyType.INFO) is True

View File

@ -289,7 +289,7 @@ TEST_URLS = (
# don't include an image by default # don't include an image by default
'include_image': False, '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), { 'i' * 24, 't' * 64), {
'instance': plugins.NotifyDiscord, 'instance': plugins.NotifyDiscord,
'requests_response_code': requests.codes.no_content, 'requests_response_code': requests.codes.no_content,