From bb4acd0bdf0bdb63f4675e766608ac3f847161a4 Mon Sep 17 00:00:00 2001 From: Alex Scotland <1638309+AlexScotland@users.noreply.github.com> Date: Fri, 29 Dec 2023 18:28:20 -0500 Subject: [PATCH] Slack Integration Support thread_timestamp (#1033) --- apprise/plugins/NotifySlack.py | 72 +++++++++++++-------- test/test_plugin_slack.py | 114 +++++++++++++++++++++++++++++++++ 2 files changed, 158 insertions(+), 28 deletions(-) diff --git a/apprise/plugins/NotifySlack.py b/apprise/plugins/NotifySlack.py index bbd2bf24..d85830be 100644 --- a/apprise/plugins/NotifySlack.py +++ b/apprise/plugins/NotifySlack.py @@ -96,6 +96,10 @@ SLACK_HTTP_ERROR_MAP = { # Used to break path apart into list of channels CHANNEL_LIST_DELIM = re.compile(r'[ \t\r\n,#\\/]+') +# Channel Regular Expression Parsing +CHANNEL_RE = re.compile( + r'^(?P[+#@]?[A-Z0-9_-]{1,32})(:(?P[0-9.]+))?$', re.I) + class SlackMode: """ @@ -547,39 +551,52 @@ class NotifySlack(NotifyBase): attach_channel_list = [] while len(channels): channel = channels.pop(0) - if channel is not None: - channel = validate_regex(channel, r'[+#@]?[A-Z0-9_]{1,32}') - if not channel: - # Channel over-ride was specified - self.logger.warning( - "The specified target {} is invalid;" - "skipping.".format(channel)) + # We'll perform a user lookup if we detect an email + email = is_email(channel) + if email: + payload['channel'] = \ + self.lookup_userid(email['full_email']) - # Mark our failure - has_error = True - continue + if not payload['channel']: + # Move along; any notifications/logging would have + # come from lookup_userid() + has_error = True + continue - if channel[0] == '+': - # Treat as encoded id if prefixed with a + - payload['channel'] = channel[1:] + else: # Channel + result = CHANNEL_RE.match(channel) - elif channel[0] == '@': - # Treat @ value 'as is' - payload['channel'] = channel + if not result: + # Channel over-ride was specified + self.logger.warning( + "The specified Slack target {} is invalid;" + "skipping.".format(channel)) - else: - # We'll perform a user lookup if we detect an email - email = is_email(channel) - if email: - payload['channel'] = \ - self.lookup_userid(email['full_email']) + # Mark our failure + has_error = True + continue + + # Store oure content + channel, thread_ts = \ + result.group('channel'), result.group('thread_ts') + if thread_ts: + payload['thread_ts'] = thread_ts + + elif 'thread_ts' in payload: + # Handle situations where one channel has a thread_id + # specified, and the next does not. We do not want to + # cary forward the last value specified + del payload['thread_ts'] + + if channel[0] == '+': + # Treat as encoded id if prefixed with a + + payload['channel'] = channel[1:] + + elif channel[0] == '@': + # Treat @ value 'as is' + payload['channel'] = channel - if not payload['channel']: - # Move along; any notifications/logging would have - # come from lookup_userid() - has_error = True - continue else: # Prefix with channel hash tag (if not already) payload['channel'] = \ @@ -795,7 +812,6 @@ class NotifySlack(NotifyBase): """ Wrapper to the requests (post) object """ - self.logger.debug('Slack POST URL: %s (cert_verify=%r)' % ( url, self.verify_certificate, )) diff --git a/test/test_plugin_slack.py b/test/test_plugin_slack.py index 11626f0c..93c88815 100644 --- a/test/test_plugin_slack.py +++ b/test/test_plugin_slack.py @@ -253,6 +253,29 @@ apprise_url_tests = ( 'test_requests_exceptions': True, 'requests_response_text': 'ok', }), + ('slack://notify@T1JJ3T3L2/A1BRTD4JD/TIiajkdnlazkcOXrIdevi7FQ/#b:100', { + 'instance': NotifySlack, + 'requests_response_text': 'ok', + }), + ('slack://notify@T1JJ3T3L2/A1BRTD4JD/TIiajkdnlazkcOXrIdevi7FQ/+124:100', { + 'instance': NotifySlack, + 'requests_response_text': 'ok', + }), + # test a case where we have a channel defined alone (without a thread_ts) + # that exists after a definition where a thread_ts does exist. this + # tests the branch of code that ensures we do not pass the same thread_ts + # twice + ('slack://notify@T1JJ3T3L2/A1BRTD4JD/' + 'TIiajkdnlazkcOXrIdevi7FQ/+124:100/@chan', { + 'instance': NotifySlack, + 'requests_response_text': 'ok', + }), + ('slack://notify@T1JJ3T3L2/A1BRTD4JD/TIiajkdnlazkcOXrIdevi7FQ/#b:bad', { + 'instance': NotifySlack, + 'requests_response_text': 'ok', + # we'll fail because our thread_ts is bad + 'response': False, + }), ) @@ -702,3 +725,94 @@ def test_plugin_slack_markdown(mock_get, mock_post): "We also want to be able to support "\ "links without the\ndescription."\ "\n\nChannel Testing\n\n" + + +@mock.patch('requests.post') +def test_plugin_slack_single_thread_reply(mock_post): + """ + NotifySlack() Send Notification as a Reply + + """ + + # Generate a (valid) bot token + token = 'xoxb-1234-1234-abc124' + thread_id = 100 + request = mock.Mock() + request.content = dumps({ + 'ok': True, + 'message': '', + 'user': { + 'id': 'ABCD1234' + } + }) + request.status_code = requests.codes.ok + + # Prepare Mock + mock_post.return_value = request + + # Variation Initializations + obj = NotifySlack(access_token=token, targets=[f'#general:{thread_id}']) + assert isinstance(obj, NotifySlack) is True + assert isinstance(obj.url(), str) is True + + # No calls made yet + assert mock_post.call_count == 0 + + # Send our notification + assert obj.notify( + body='body', title='title', notify_type=NotifyType.INFO) is True + + # Post was made + assert mock_post.call_count == 1 + assert mock_post.call_args_list[0][0][0] == \ + 'https://slack.com/api/chat.postMessage' + assert loads(mock_post.call_args_list[0][1]['data']).get("thread_ts") \ + == str(thread_id) + + +@mock.patch('requests.post') +def test_plugin_slack_multiple_thread_reply(mock_post): + """ + NotifySlack() Send Notification to multiple channels as Reply + + """ + + # Generate a (valid) bot token + token = 'xoxb-1234-1234-abc124' + thread_id_1, thread_id_2 = 100, 200 + request = mock.Mock() + request.content = dumps({ + 'ok': True, + 'message': '', + 'user': { + 'id': 'ABCD1234' + } + }) + request.status_code = requests.codes.ok + + # Prepare Mock + mock_post.return_value = request + + # Variation Initializations + obj = NotifySlack(access_token=token, + targets=[ + f'#general:{thread_id_1}', + f'#other:{thread_id_2}']) + assert isinstance(obj, NotifySlack) is True + assert isinstance(obj.url(), str) is True + + # No calls made yet + assert mock_post.call_count == 0 + + # Send our notification + assert obj.notify( + body='body', title='title', notify_type=NotifyType.INFO) is True + + # Post was made + assert mock_post.call_count == 2 + assert mock_post.call_args_list[0][0][0] == \ + 'https://slack.com/api/chat.postMessage' + assert loads(mock_post.call_args_list[0][1]['data']).get("thread_ts") \ + == str(thread_id_1) + assert loads(mock_post.call_args_list[1][1]['data']).get("thread_ts") \ + == str(thread_id_2)