Skip to content
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
10 changes: 10 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down Expand Up @@ -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
Expand Down
8 changes: 6 additions & 2 deletions automator/config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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:
Expand Down
60 changes: 60 additions & 0 deletions automator/lambda_function.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}


Expand Down
16 changes: 16 additions & 0 deletions integration_tests/test.py
Original file line number Diff line number Diff line change
Expand Up @@ -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')

Expand Down Expand Up @@ -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()
Expand Down
231 changes: 231 additions & 0 deletions tests/test_automator_lambda.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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 <link|these recommendations>. 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()