From ccb97bc92e3a04283b3a5c30d0aedfa988c854dc Mon Sep 17 00:00:00 2001 From: Chris Caron Date: Sun, 19 Nov 2023 11:13:27 -0500 Subject: [PATCH] Discord user/role ping support added (#1004) --- apprise/plugins/NotifyDiscord.py | 41 +++++++++- test/test_plugin_discord.py | 128 +++++++++++++++++++++++++++++++ 2 files changed, 167 insertions(+), 2 deletions(-) diff --git a/apprise/plugins/NotifyDiscord.py b/apprise/plugins/NotifyDiscord.py index f87b6694..839980a4 100644 --- a/apprise/plugins/NotifyDiscord.py +++ b/apprise/plugins/NotifyDiscord.py @@ -60,6 +60,11 @@ from ..AppriseLocale import gettext_lazy as _ from ..attachment.AttachBase import AttachBase +# Used to detect user/role IDs +USER_ROLE_DETECTION_RE = re.compile( + r'\s*(?:<@(?P&?)(?P[0-9]+)>|@(?P[a-z0-9]+))', re.I) + + class NotifyDiscord(NotifyBase): """ A wrapper to Discord Notifications @@ -336,6 +341,33 @@ class NotifyDiscord(NotifyBase): 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 @@ -360,16 +392,21 @@ class NotifyDiscord(NotifyBase): '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)) diff --git a/test/test_plugin_discord.py b/test/test_plugin_discord.py index 633de128..e0d93627 100644 --- a/test/test_plugin_discord.py +++ b/test/test_plugin_discord.py @@ -32,6 +32,7 @@ from datetime import datetime, timedelta from datetime import timezone import pytest import requests +from json import loads from apprise.plugins.NotifyDiscord import NotifyDiscord from helpers import AppriseURLTester @@ -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): """ @@ -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)