diff --git a/README.md b/README.md index b3c9f65..43ef857 100644 --- a/README.md +++ b/README.md @@ -82,6 +82,7 @@ This section defines automated responses to specific reactions or triggers in th 2. `DELETE_MESSAGE`: Removes a message and sends a notification to the user 3. `ASK_AI`: Utilizes an AI model to generate a response 4. `REMOVE_BROADCAST`: Removes a broadcasted thread reply from the channel (keeps it in the thread) +5. `REPOST_TO_THREAD_AND_DELETE`: Reposts the original message to the thread with a custom message, then deletes it from the channel #### Multiple Handlers: @@ -140,6 +141,15 @@ For reactions with channel-specific placeholders (like `{link}` in the `faq` rea - `{link}`: Channel-specific guidelines for sharing error logs - Default behavior: Uses a default link if the channel doesn't match +4a. `error-log-to-thread-and-delete`: + - Type: `REPOST_TO_THREAD_AND_DELETE` + - Action: Reposts the original message to the thread with instructions, then deletes it from the channel to save space + - Placeholders: + - `{link}`: Channel-specific guidelines for sharing error logs + - `{user_message}`: The original message content + - Default behavior: Uses a default link if the channel doesn't match + - Note: This is an enhanced version of `error-log-to-thread-please` that also removes the message from the channel + 5. `no-screenshot`: - Type: `SLACK_POST` - Action: Advises against posting screenshots of code diff --git a/automator/config.yaml b/automator/config.yaml index 0f2e218..3677b38 100644 --- a/automator/config.yaml +++ b/automator/config.yaml @@ -49,7 +49,7 @@ reactions: Please check the <{link}|FAQ> - reaction: error-log-to-thread-please - type: SLACK_POST + type: REPOST_TO_THREAD_AND_DELETE placeholders: link: course-ml-zoomcamp: https://github.com/DataTalksClub/machine-learning-zoomcamp/blob/master/asking-questions.md @@ -58,12 +58,16 @@ reactions: course-llm-zoomcamp: https://github.com/DataTalksClub/llm-zoomcamp/blob/main/asking-questions.md default: https://datatalks.club/slack/guidelines.html#code-problems-and-errors message: | - Please move the error log from the main message to the thread. + Removing the message from the channel to save space. Please move the error log to the thread. Use code block for formatting the log: https://slack.com/help/articles/202288908-Format-your-messages Follow <{link}|these recommendations> to make it easier to help you. + Here's the original message: + + > {user_message} + - reaction: no-screenshot type: SLACK_POST placeholders: diff --git a/automator/lambda_function.py b/automator/lambda_function.py index c50cef7..2569168 100644 --- a/automator/lambda_function.py +++ b/automator/lambda_function.py @@ -172,11 +172,71 @@ def handle_ask_ai(event, reaction_config): slack.post_message_thread(event, message) +def handle_repost_to_thread_and_delete(event, reaction_config): + """Repost the original message to the thread with a custom message, then delete from channel""" + item = event['item'] + channel = item['channel'] + ts = item['ts'] + channel_name = get_channel_name(channel) + + # Get the original message content + message_details = slack.get_message_content(channel, ts) + if not message_details: + logger.info(f"Message not found for {channel} {ts}") + return + + user = message_details.get('user') + original_message = message_details.get('text', '') + + # Skip if there's no user (e.g., bot message or deleted message) + if not user: + logger.info(f"Message has no user for {channel} {ts}") + return + + # Escape curly braces in user message to avoid format string issues + original_message_escaped = original_message.replace('{', '{{').replace('}', '}}') + + # Format the thread message with placeholders + message_pattern = reaction_config['message'] + + values = { + 'user': user, + 'user_message': original_message_escaped, + 'channel': channel, + } + + if 'placeholders' in reaction_config: + thread_message = util.format_message( + message_pattern, + reaction_config['placeholders'], + channel_name + ) + if thread_message is None: + logger.info(f"No placeholder matched for channel {channel_name}") + return + # Now format with user and user_message + thread_message = thread_message.format(**values) + else: + thread_message = message_pattern.format(**values) + + # Post the message to the thread + slack.post_message_thread(event, thread_message) + logger.info(f"Reposted message to thread for {channel} {ts}") + + # Delete the original message from the channel + if FAKE_DELETE: + logger.info(f"FAKE_DELETE for {channel} {ts}") + else: + slack.remove_message(channel, ts) + logger.info(f"Deleted original message from channel {channel} (kept in thread)") + + action_handlers = { 'SLACK_POST': handle_slack_post, 'DELETE_MESSAGE': handle_delete_message, 'ASK_AI': handle_ask_ai, 'REMOVE_BROADCAST': handle_remove_broadcast, + 'REPOST_TO_THREAD_AND_DELETE': handle_repost_to_thread_and_delete, } diff --git a/integration_tests/test.py b/integration_tests/test.py index 8aedc97..47e6904 100644 --- a/integration_tests/test.py +++ b/integration_tests/test.py @@ -65,6 +65,19 @@ def test_error_log_to_thread_please_other_channel(): # should send a default message +def test_error_log_to_thread_and_delete_de_zoomcamp(): + trigger_reaction_for_channel('error-log-to-thread-and-delete', 'course-data-engineering') + + +def test_error_log_to_thread_and_delete_ml_zoomcamp(): + trigger_reaction_for_channel('error-log-to-thread-and-delete', 'course-ml-zoomcamp') + + +def test_error_log_to_thread_and_delete_other_channel(): + trigger_reaction_for_channel('error-log-to-thread-and-delete', 'general') + # should send a default message and delete + + def test_no_screenshot_de_zoomcamp(): trigger_reaction_for_channel('no-screenshot', 'course-data-engineering') @@ -111,6 +124,9 @@ def run(): # test_error_log_to_thread_please_de_zoomcamp() # test_error_log_to_thread_please_ml_zoomcamp() # test_error_log_to_thread_please_other_channel() + # test_error_log_to_thread_and_delete_de_zoomcamp() + # test_error_log_to_thread_and_delete_ml_zoomcamp() + # test_error_log_to_thread_and_delete_other_channel() # test_no_screenshot_de_zoomcamp() # test_no_screenshot_other_channel() # test_shameless_rules() diff --git a/tests/test_automator_lambda.py b/tests/test_automator_lambda.py index 913d488..aa011f3 100644 --- a/tests/test_automator_lambda.py +++ b/tests/test_automator_lambda.py @@ -312,6 +312,27 @@ def test_thread_reaction_uses_single_handler(self): self.assertEqual(reaction_config['type'], 'SLACK_POST') # Should not have 'types' (multiple handlers) self.assertNotIn('types', reaction_config) + + def test_error_log_to_thread_and_delete_reaction_exists(self): + """Verify that 'error-log-to-thread-and-delete' reaction is configured""" + reaction_config = lambda_function.reaction_configs.get('error-log-to-thread-please') + self.assertIsNotNone(reaction_config) + self.assertEqual(reaction_config['type'], 'REPOST_TO_THREAD_AND_DELETE') + self.assertIn('message', reaction_config) + self.assertIn('placeholders', reaction_config) + # Verify the message contains expected text + message = reaction_config['message'] + self.assertIn('Removing the message', message) + self.assertIn('original message', message.lower()) + self.assertIn('{user_message}', message) + + def test_action_handlers_has_repost_to_thread_and_delete(self): + """Verify that REPOST_TO_THREAD_AND_DELETE handler is registered""" + self.assertIn('REPOST_TO_THREAD_AND_DELETE', lambda_function.action_handlers) + self.assertEqual( + lambda_function.action_handlers['REPOST_TO_THREAD_AND_DELETE'], + lambda_function.handle_repost_to_thread_and_delete + ) class TestRemoveBroadcast(unittest.TestCase): @@ -544,5 +565,215 @@ def test_ask_ai_with_curly_braces_in_user_message(self, mock_slack, mock_groqu): mock_slack.post_message_thread.assert_called_once() +class TestRepostToThreadAndDelete(unittest.TestCase): + """Test REPOST_TO_THREAD_AND_DELETE handler""" + + @patch('automator_lambda_function.slack') + @patch('automator_lambda_function.util') + def test_repost_with_placeholders(self, mock_util, mock_slack): + """Test REPOST_TO_THREAD_AND_DELETE with channel-specific placeholders""" + # Setup mocks + mock_slack.get_message_content.return_value = { + 'user': 'U123456', + 'text': 'Here is my error log with lots of text' + } + mock_util.format_message.return_value = "Removing the message. Follow . Here's the original message:\n\n> {user_message}" + + # Create event + event = { + 'item': { + 'channel': 'C123456', + 'ts': '1234567890.123456' + }, + 'reaction': 'error-log-to-thread-please' + } + + # Create reaction config with placeholders + reaction_config = { + 'message': "Removing the message. Follow <{link}|these recommendations>. Here's the original message:\n\n> {user_message}", + 'type': 'REPOST_TO_THREAD_AND_DELETE', + 'placeholders': { + 'link': { + 'course-ml-zoomcamp': 'https://github.com/DataTalksClub/machine-learning-zoomcamp/blob/master/asking-questions.md', + 'default': 'https://datatalks.club/slack/guidelines.html' + } + } + } + + # Execute + lambda_function.handle_repost_to_thread_and_delete(event, reaction_config) + + # Verify + mock_slack.get_message_content.assert_called_once_with('C123456', '1234567890.123456') + mock_slack.post_message_thread.assert_called_once() + # Verify the posted message contains the original message + posted_message = mock_slack.post_message_thread.call_args[0][1] + self.assertIn('Here is my error log with lots of text', posted_message) + + @patch('automator_lambda_function.slack') + def test_repost_without_placeholders(self, mock_slack): + """Test REPOST_TO_THREAD_AND_DELETE without placeholders""" + # Setup mocks + mock_slack.get_message_content.return_value = { + 'user': 'U123456', + 'text': 'Error message here' + } + + # Create event + event = { + 'item': { + 'channel': 'C123456', + 'ts': '1234567890.123456' + }, + 'reaction': 'error-log-to-thread-please' + } + + # Create reaction config without placeholders + reaction_config = { + 'message': 'Removing from channel. Original message:\n\n> {user_message}', + 'type': 'REPOST_TO_THREAD_AND_DELETE' + } + + # Execute + lambda_function.handle_repost_to_thread_and_delete(event, reaction_config) + + # Verify + mock_slack.post_message_thread.assert_called_once() + posted_message = mock_slack.post_message_thread.call_args[0][1] + self.assertIn('Error message here', posted_message) + + @patch('automator_lambda_function.slack') + def test_repost_message_not_found(self, mock_slack): + """Test REPOST_TO_THREAD_AND_DELETE handles missing message gracefully""" + # Setup mocks - message not found + mock_slack.get_message_content.return_value = None + + # Create event + event = { + 'item': { + 'channel': 'C123456', + 'ts': '1234567890.123456' + }, + 'reaction': 'error-log-to-thread-please' + } + + # Create reaction config + reaction_config = { + 'message': 'Message: {user_message}', + 'type': 'REPOST_TO_THREAD_AND_DELETE' + } + + # Execute + lambda_function.handle_repost_to_thread_and_delete(event, reaction_config) + + # Verify - should not post or delete when message not found + mock_slack.post_message_thread.assert_not_called() + mock_slack.remove_message.assert_not_called() + + @patch('automator_lambda_function.slack') + def test_repost_no_user(self, mock_slack): + """Test REPOST_TO_THREAD_AND_DELETE handles missing user gracefully""" + # Setup mocks - message without user + mock_slack.get_message_content.return_value = { + 'text': 'Bot message without user' + } + + # Create event + event = { + 'item': { + 'channel': 'C123456', + 'ts': '1234567890.123456' + }, + 'reaction': 'error-log-to-thread-please' + } + + # Create reaction config + reaction_config = { + 'message': 'Message: {user_message}', + 'type': 'REPOST_TO_THREAD_AND_DELETE' + } + + # Execute + lambda_function.handle_repost_to_thread_and_delete(event, reaction_config) + + # Verify - should not post or delete when no user + mock_slack.post_message_thread.assert_not_called() + mock_slack.remove_message.assert_not_called() + + @patch('automator_lambda_function.slack') + def test_repost_with_curly_braces_in_message(self, mock_slack): + """Test REPOST_TO_THREAD_AND_DELETE handles curly braces in user message""" + # Setup mocks - message with curly braces + mock_slack.get_message_content.return_value = { + 'user': 'U123456', + 'text': 'How to configure {POSTGRES_DB} and {POSTGRES_USER}?' + } + + # Create event + event = { + 'item': { + 'channel': 'C123456', + 'ts': '1234567890.123456' + }, + 'reaction': 'error-log-to-thread-please' + } + + # Create reaction config + reaction_config = { + 'message': 'Original: {user_message}', + 'type': 'REPOST_TO_THREAD_AND_DELETE' + } + + # Execute - should not raise KeyError + lambda_function.handle_repost_to_thread_and_delete(event, reaction_config) + + # Verify + mock_slack.post_message_thread.assert_called_once() + posted_message = mock_slack.post_message_thread.call_args[0][1] + # Should contain the escaped braces + self.assertIn('POSTGRES_DB', posted_message) + self.assertIn('POSTGRES_USER', posted_message) + + @patch('automator_lambda_function.slack') + @patch('automator_lambda_function.util') + def test_repost_placeholder_returns_none(self, mock_util, mock_slack): + """Test REPOST_TO_THREAD_AND_DELETE handles placeholder matching failure gracefully""" + # Setup mocks + mock_slack.get_message_content.return_value = { + 'user': 'U123456', + 'text': 'Error message' + } + # util.format_message returns None when no placeholder matches and no default + mock_util.format_message.return_value = None + + # Create event + event = { + 'item': { + 'channel': 'C123456', + 'ts': '1234567890.123456' + }, + 'reaction': 'error-log-to-thread-please' + } + + # Create reaction config with placeholders but no default + reaction_config = { + 'message': 'Message with {link}', + 'type': 'REPOST_TO_THREAD_AND_DELETE', + 'placeholders': { + 'link': { + 'course-ml-zoomcamp': 'https://example.com' + # No default value + } + } + } + + # Execute + lambda_function.handle_repost_to_thread_and_delete(event, reaction_config) + + # Verify - should not post or delete when placeholder matching fails + mock_slack.post_message_thread.assert_not_called() + mock_slack.remove_message.assert_not_called() + + if __name__ == '__main__': unittest.main()