mirror of https://github.com/caronc/apprise
Discord markdown enhancements (#295)
parent
49faa9a201
commit
784e073eea
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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,
|
||||||
|
|
Loading…
Reference in New Issue