Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Discord user/role ping/notification support added #1004

Merged
merged 4 commits into from
Nov 19, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 39 additions & 2 deletions apprise/plugins/NotifyDiscord.py
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,11 @@
from ..attachment.AttachBase import AttachBase


# Used to detect user/role IDs
USER_ROLE_DETECTION_RE = re.compile(
r'\s*(?:<@(?P<role>&?)(?P<id>[0-9]+)>|@(?P<value>[a-z0-9]+))', re.I)


class NotifyDiscord(NotifyBase):
"""
A wrapper to Discord Notifications
Expand Down Expand Up @@ -336,6 +341,33 @@ def send(self, body, title='', notify_type=NotifyType.INFO, attach=None,
payload['content'] = \
body if not title else "{}\r\n{}".format(title, body)

# parse for user id's <@123> and role IDs <@&456>
results = USER_ROLE_DETECTION_RE.findall(body)
if results:
payload['allow_mentions'] = {
'parse': [],
'users': [],
'roles': [],
}

_content = []
for (is_role, no, value) in results:
if value:
payload['allow_mentions']['parse'].append(value)
_content.append(f'@{value}')

elif is_role:
payload['allow_mentions']['roles'].append(no)
_content.append(f'<@&{no}>')

else: # is_user
payload['allow_mentions']['users'].append(no)
_content.append(f'<@{no}>')

if self.notify_format == NotifyFormat.MARKDOWN:
# Add pingable elements to content field
payload['content'] = '👉 ' + ' '.join(_content)

if not self._send(payload, params=params):
# We failed to post our message
return False
Expand All @@ -360,16 +392,21 @@ def send(self, body, title='', notify_type=NotifyType.INFO, attach=None,
'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']

if 'allow_mentions' in payload:
del payload['allow_mentions']

#
# Send our attachments
#
for attachment in attach:
self.logger.info(
'Posting Discord Attachment {}'.format(attachment.name))
Expand Down
128 changes: 128 additions & 0 deletions test/test_plugin_discord.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
from datetime import timezone
import pytest
import requests
from json import loads

from apprise.plugins.NotifyDiscord import NotifyDiscord
from helpers import AppriseURLTester
Expand Down Expand Up @@ -184,6 +185,113 @@ def test_plugin_discord_urls():
AppriseURLTester(tests=apprise_url_tests).run_all()


@mock.patch('requests.post')
def test_plugin_discord_notifications(mock_post):
"""
NotifyDiscord() Notifications/Ping Support

"""

# 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

# Test our header parsing when not lead with a header
body = """
# Heading
@everyone and @admin, wake and meet our new user <@123>; <@&456>"
"""

results = NotifyDiscord.parse_url(
f'discord://{webhook_id}/{webhook_token}/?format=markdown')

assert isinstance(results, dict)
assert results['user'] is None
assert results['webhook_id'] == webhook_id
assert results['webhook_token'] == webhook_token
assert results['password'] is None
assert results['port'] is None
assert results['host'] == webhook_id
assert results['fullpath'] == f'/{webhook_token}/'
assert results['path'] == f'/{webhook_token}/'
assert results['query'] is None
assert results['schema'] == 'discord'
assert results['url'] == f'discord://{webhook_id}/{webhook_token}/'

instance = NotifyDiscord(**results)
assert isinstance(instance, NotifyDiscord)

response = instance.send(body=body)
assert response is True
assert mock_post.call_count == 1

details = mock_post.call_args_list[0]
assert details[0][0] == \
f'https://discord.com/api/webhooks/{webhook_id}/{webhook_token}'

payload = loads(details[1]['data'])

assert 'allow_mentions' in payload
assert 'users' in payload['allow_mentions']
assert len(payload['allow_mentions']['users']) == 1
assert '123' in payload['allow_mentions']['users']
assert 'roles' in payload['allow_mentions']
assert len(payload['allow_mentions']['roles']) == 1
assert '456' in payload['allow_mentions']['roles']
assert 'parse' in payload['allow_mentions']
assert len(payload['allow_mentions']['parse']) == 2
assert 'everyone' in payload['allow_mentions']['parse']
assert 'admin' in payload['allow_mentions']['parse']

# Reset our object
mock_post.reset_mock()

results = NotifyDiscord.parse_url(
f'discord://{webhook_id}/{webhook_token}/?format=text')

assert isinstance(results, dict)
assert results['user'] is None
assert results['webhook_id'] == webhook_id
assert results['webhook_token'] == webhook_token
assert results['password'] is None
assert results['port'] is None
assert results['host'] == webhook_id
assert results['fullpath'] == f'/{webhook_token}/'
assert results['path'] == f'/{webhook_token}/'
assert results['query'] is None
assert results['schema'] == 'discord'
assert results['url'] == f'discord://{webhook_id}/{webhook_token}/'

instance = NotifyDiscord(**results)
assert isinstance(instance, NotifyDiscord)

response = instance.send(body=body)
assert response is True
assert mock_post.call_count == 1

details = mock_post.call_args_list[0]
assert details[0][0] == \
f'https://discord.com/api/webhooks/{webhook_id}/{webhook_token}'

payload = loads(details[1]['data'])

assert 'allow_mentions' in payload
assert 'users' in payload['allow_mentions']
assert len(payload['allow_mentions']['users']) == 1
assert '123' in payload['allow_mentions']['users']
assert 'roles' in payload['allow_mentions']
assert len(payload['allow_mentions']['roles']) == 1
assert '456' in payload['allow_mentions']['roles']
assert 'parse' in payload['allow_mentions']
assert len(payload['allow_mentions']['parse']) == 2
assert 'everyone' in payload['allow_mentions']['parse']
assert 'admin' in payload['allow_mentions']['parse']


@mock.patch('requests.post')
def test_plugin_discord_general(mock_post):
"""
Expand Down Expand Up @@ -564,6 +672,26 @@ def test_plugin_discord_attachments(mock_post):
'https://discord.com/api/webhooks/{}/{}'.format(
webhook_id, webhook_token)

# Reset our object
mock_post.reset_mock()

# Test notifications with mentions and attachments in it
assert obj.notify(
body='Say hello to <@1234>!', notify_type=NotifyType.INFO,
attach=attach) is True

# Test our call count
assert mock_post.call_count == 2
assert mock_post.call_args_list[0][0][0] == \
'https://discord.com/api/webhooks/{}/{}'.format(
webhook_id, webhook_token)
assert mock_post.call_args_list[1][0][0] == \
'https://discord.com/api/webhooks/{}/{}'.format(
webhook_id, webhook_token)

# Reset our object
mock_post.reset_mock()

# An invalid attachment will cause a failure
path = os.path.join(TEST_VAR_DIR, '/invalid/path/to/an/invalid/file.jpg')
attach = AppriseAttachment(path)
Expand Down