diff --git a/app/integrations/google_drive.py b/app/integrations/google_drive.py index 184d8088..fc372f73 100644 --- a/app/integrations/google_drive.py +++ b/app/integrations/google_drive.py @@ -3,6 +3,7 @@ import base64 import logging import datetime +import re from dotenv import load_dotenv from googleapiclient.discovery import build @@ -312,6 +313,14 @@ def get_timeline_section(document_id): text_run = elem.get("textRun") if text_run: text = text_run.get("content") + textStyle = text_run.get("textStyle", {}) + if "link" in textStyle: + # Extract link URL + link = textStyle["link"].get("url") + # Format the text with the link as Markdown + formatted_text = f"[{text.strip()}]({link})" + # Replace the text with the formatted text + text = formatted_text if START_HEADING in text: record = True found_start = True @@ -326,6 +335,22 @@ def get_timeline_section(document_id): return None if not (found_start and found_end) else timeline_content +def find_heading_indices(content, start_heading, end_heading): + """Find the start and end indices of content between two headings.""" + start_index, end_index = None, None + for element in content: + if "paragraph" in element: + text_runs = element["paragraph"].get("elements", []) + for text_run in text_runs: + text = text_run.get("textRun", {}).get("content", "") + if start_heading in text: + start_index = text_run.get("endIndex") + elif end_heading in text and start_index is not None: + end_index = text_run.get("startIndex") + return start_index, end_index + return start_index, end_index + + # Replace the text between the headings def replace_text_between_headings(doc_id, new_content, start_heading, end_heading): # Setup the service @@ -336,83 +361,119 @@ def replace_text_between_headings(doc_id, new_content, start_heading, end_headin content = document.get("body").get("content") # Find the start and end indices - start_index = None - end_index = None - for element in content: - if "paragraph" in element: - paragraph = element.get("paragraph") - text_runs = paragraph.get("elements") - for text_run in text_runs: - text = text_run.get("textRun").get("content") - if start_heading in text: - # Set start_index to the end of the start heading - start_index = text_run.get("endIndex") - if end_heading in text and start_index is not None: - # Set end_index to the start of the end heading - end_index = text_run.get("startIndex") - break + start_index, end_index = find_heading_indices(content, start_heading, end_heading) if start_index is not None and end_index is not None: - # Format new content with new lines for proper insertion. We need to make sure that the formatted string contains only one - # leading and trailing newline character - # Split the string into three parts: leading newlines, core content, and trailing newlines - leading_newlines = len(new_content) - len(new_content.lstrip("\n")) - trailing_newlines = len(new_content) - len(new_content.rstrip("\n")) - core_content = new_content[ - leading_newlines : len(new_content) - trailing_newlines - ] - - # Ensure only one newline at the start and one at the end, preserving internal newlines - formatted_content = "\n" + core_content + "\n" - content_length = len(formatted_content) - - # Perform the replacement + # Delete the existing content from the document requests = [ { "deleteContentRange": { "range": {"startIndex": start_index, "endIndex": end_index} } - }, - { - "insertText": { - "location": {"index": start_index}, - "text": formatted_content, - } - }, - ] - # Format the inserted text - we want to make sure that the font size is what we want - requests.append( - { - "updateTextStyle": { - "range": { - "startIndex": start_index, - "endIndex": ( - start_index + content_length - ), # Adjust this index based on the length of the text - }, - "textStyle": { - "fontSize": {"magnitude": 11, "unit": "PT"}, - "bold": False, - }, - "fields": "bold", - } } - ) - # Update paragraph style to be normal text + ] + + # split the formatted content by the emoji + line = new_content.split(" ➡ ") + pattern = r"\[([^\]]+)\]\(([^)]+)\)\s([^:]+):\s(.+)" + insert_index = start_index + inserted_content = "" + + # Insert an empty line before the new content and after the placeholder text + text_to_insert = "\n" + text_len = len(text_to_insert) requests.append( { - "updateParagraphStyle": { - "range": { - "startIndex": start_index, - "endIndex": ( - start_index + content_length - ), # Adjust this index based on the length of the text - }, - "paragraphStyle": {"namedStyleType": "NORMAL_TEXT"}, - "fields": "namedStyleType", + "insertText": { + "location": {"index": insert_index}, + "text": text_to_insert, } } ) + # udpate the insert index + insert_index += text_len + + for item in line: + # split the item by the emoji and strip out any empty strings + original_entries = item.split("➡️ ") + entries = [entry for entry in original_entries if entry.strip()] + + for entry in entries: + # Regular expression to match the entry pattern + pattern = r"\[(?P.+?) ET\]\((?P.+?)\) (?P.+?): (?P.+)$" + + # Use re.DOTALL to make '.' match newline characters as well. This is needed for multi-line messages + match = re.match(pattern, entry, re.DOTALL) + + if match: + # Extract components from the match object + date = match.group("date") + " ET" + url = match.group("url") + name = match.group("name") + message = match.group( + "message" + ).strip() # Remove leading/trailing whitespace + + # Construct the text to be inserted with the date as a link + text_to_insert = f" ➡️ {date} {name}: {message}\n" + text_len = len(text_to_insert) + inserted_content += text_to_insert + + # Insert text request + requests.append( + { + "insertText": { + "location": {"index": insert_index}, + "text": text_to_insert, + } + } + ) + # Update link style for date_text + requests.append( + { + "updateTextStyle": { + "range": { + "startIndex": insert_index + 4, + "endIndex": insert_index + len(date) + 4, + }, + "textStyle": {"link": {"url": url}}, + "fields": "link", + } + } + ) + # Update for next insertion + insert_index += text_len + else: + # if we don't match the above pattern, just insert the entry as is + text_to_insert = f" ➡️ {item}\n" + inserted_content += text_to_insert + text_len = len(text_to_insert) + # Insert text request for the entire block of formatted_content + requests.append( + { + "insertText": { + "location": {"index": insert_index}, + "text": text_to_insert, + } + } + ) + + # Update insert_index as needed, assuming formatted_content is a single block of text + insert_index += text_len + + # Make sure that we do normal formatting for the inserted content + requests.append( + { + "updateParagraphStyle": { + "range": { + "startIndex": start_index, + "endIndex": (start_index + len(inserted_content)), + }, + "paragraphStyle": {"namedStyleType": "NORMAL_TEXT"}, + "fields": "namedStyleType", + } + } + ) service.documents().batchUpdate( documentId=doc_id, body={"requests": requests} ).execute() diff --git a/app/modules/incident/handle_slack_message_reactions.py b/app/modules/incident/handle_slack_message_reactions.py index 489b4dcd..506c5e56 100644 --- a/app/modules/incident/handle_slack_message_reactions.py +++ b/app/modules/incident/handle_slack_message_reactions.py @@ -4,55 +4,37 @@ def rearrange_by_datetime_ascending(text): - # Split the text by lines lines = text.split("\n") - - # Temporary storage for multiline entries entries = [] - current_entry = [] - - # Iterate over each line - for line in lines: - # Check if the line starts with a datetime format including 'ET' - if re.match(r"\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2} ET", line): - if current_entry: - # Combine the lines in current_entry and add to entries - entries.append("\n".join(current_entry)) - current_entry = [line] - else: - current_entry.append(line) - else: - # If not a datetime, it's a continuation of the previous message - current_entry.append(line) - # Add the last entry - if current_entry: - if current_entry.__len__() > 1: - # that means we have a multiline entry - joined_current_entry = "\n".join(current_entry) - entries.append(joined_current_entry) - else: - entries.append("\n".join(current_entry)) + pattern = r"\s*➡️\s*\[(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}) ET\]\((https?://[\w./-]+)\)\s([\w\s]+):\s" - # Now extract date, time, and message from each entry - dated_entries = [] - for entry in entries: - match = re.match( - r"(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2} ET):?[\s,]*(.*)", entry, re.DOTALL - ) + current_message = [] + for line in lines: + match = re.match(pattern, line) if match: - date_str, msg = match.groups() - # Parse the datetime string (ignoring 'ET' for parsing) - dt = datetime.strptime(date_str[:-3].strip(), "%Y-%m-%d %H:%M:%S") - dated_entries.append((dt, msg)) + if ( + current_message + ): # If there's a current message, finalize it before starting a new one + entries.append(current_message) + current_message = [] + date_str, url, name = match.groups() + dt = datetime.strptime(date_str.strip(), "%Y-%m-%d %H:%M:%S") + msg_start = line[match.end() :].strip(" ") + current_message = [dt, url, f"{name}:", msg_start] + elif current_message: # If it's a continuation of the current message + current_message[-1] += "\n" + f"{line.strip()}" + + if current_message: # Don't forget to append the last message + entries.append(current_message) # Sort the entries by datetime in ascending order - sorted_entries = sorted(dated_entries, key=lambda x: x[0], reverse=False) + sorted_entries = sorted(entries, key=lambda x: x[0]) - # Reformat the entries back into strings, including 'ET' - sorted_text = "\n".join( + # Reformat the entries back into strings, including 'ET' and the full message + sorted_text = "\n\n".join( [ - f"{entry[0].strftime('%Y-%m-%d %H:%M:%S')} ET {entry[1]}" + f"➡️ [{entry[0].strftime('%Y-%m-%d %H:%M:%S')} ET]({entry[1]}) {entry[2]} {entry[3]}" for entry in sorted_entries ] ) diff --git a/app/modules/incident/incident.py b/app/modules/incident/incident.py index 877f0a67..027073b2 100644 --- a/app/modules/incident/incident.py +++ b/app/modules/incident/incident.py @@ -443,9 +443,19 @@ def handle_reaction_added(client, ack, body, logger): ) if document_id == "": logger.error("No incident document found for this channel.") + for message in messages: + # get the message ts time + message_ts = message["ts"] + # convert the time which is now in epoch time to standard ET Time - message_date_time = convert_epoch_to_datetime_est(message["ts"]) + message_date_time = convert_epoch_to_datetime_est(message_ts) + + # get a link to the message + link = client.chat_getPermalink( + channel=channel_id, message_ts=message_ts + )["permalink"] + # get the user name from the message user = client.users_profile_get(user=message["user"]) # get the full name of the user so that we include it into the timeline @@ -462,7 +472,9 @@ def handle_reaction_added(client, ack, body, logger): # if the message already exists in the timeline, then don't put it there again if content and message_date_time not in content: # append the new message to the content - content += f"{message_date_time} {user_full_name}: {message}" + content += ( + f" ➡️ [{message_date_time}]({link}) {user_full_name}: {message}" + ) # if there is an image in the message, then add it to the timeline if "files" in message: @@ -500,8 +512,11 @@ def handle_reaction_removed(client, ack, body, logger): # get the message we want to delete message = messages[0] + # get the message ts time + message_ts = message["ts"] + # convert the epoch time to standard ET day/time - message_date_time = convert_epoch_to_datetime_est(message["ts"]) + message_date_time = convert_epoch_to_datetime_est(message_ts) # get the user of the person that send the message user = client.users_profile_get(user=message["user"]) @@ -528,8 +543,16 @@ def handle_reaction_removed(client, ack, body, logger): user_handle, message["text"] ) + # get a link to the message + link = client.chat_getPermalink(channel=channel_id, message_ts=message_ts)[ + "permalink" + ] + # Construct the message to remove - message_to_remove = f"\n{message_date_time} {user_full_name}: {message}\n" + message_to_remove = ( + f" ➡️ [{message_date_time}]({link}) {user_full_name}: {message}\n" + ) + # if there is a file in the message, then add it to the message to remove if "files" in message: image = message["files"][0]["url_private"] diff --git a/app/tests/intergrations/test_google_drive.py b/app/tests/intergrations/test_google_drive.py index ad178b19..56457399 100644 --- a/app/tests/intergrations/test_google_drive.py +++ b/app/tests/intergrations/test_google_drive.py @@ -569,6 +569,92 @@ def test_replace_text_between_headings_neither_heading_not_found(mock_service): assert not mock_service.return_value.documents().batchUpdate.called +def test_no_headings_present_find_heading_indices(): + content = [ + { + "paragraph": { + "elements": [ + { + "startIndex": 1, + "endIndex": 10, + "textRun": {"content": "Some text"}, + } + ] + } + } + ] + assert google_drive.find_heading_indices(content, START_HEADING, END_HEADING) == ( + None, + None, + ) + + +def test_only_start_heading_present_find_heading_indices(): + content = [ + { + "paragraph": { + "elements": [ + { + "startIndex": 1, + "endIndex": 13, + "textRun": {"content": START_HEADING}, + } + ] + } + } + ] + assert google_drive.find_heading_indices(content, START_HEADING, END_HEADING) == ( + 13, + None, + ) + + +def test_both_headings_present_find_heading_indices(): + content = [ + { + "paragraph": { + "elements": [ + { + "startIndex": 1, + "endIndex": 14, + "textRun": {"content": START_HEADING, "endIndex": 13}, + } + ] + } + }, + { + "paragraph": { + "elements": [ + { + "startIndex": 17, + "endIndex": 24, + "textRun": {"content": "Some text", "endIndex": 22}, + } + ] + } + }, + { + "paragraph": { + "elements": [ + { + "startIndex": 25, + "endIndex": 34, + "textRun": { + "content": END_HEADING, + "startIndex": 23, + "endIndex": 33, + }, + } + ] + } + }, + ] + assert google_drive.find_heading_indices(content, START_HEADING, END_HEADING) == ( + 14, + 25, + ) + + @patch("integrations.google_drive.list_metadata") def test_healthcheck_healthy(mock_list_metadata): mock_list_metadata.return_value = {"id": "test_doc"} diff --git a/app/tests/modules/incident/test_handle_slack_message_reactions.py b/app/tests/modules/incident/test_handle_slack_message_reactions.py index fc14e28d..73c82c9b 100644 --- a/app/tests/modules/incident/test_handle_slack_message_reactions.py +++ b/app/tests/modules/incident/test_handle_slack_message_reactions.py @@ -2,46 +2,62 @@ from modules.incident import handle_slack_message_reactions -def test_basic_functionality_rearrange_by_datetime_ascending(): - input_text = "2024-01-01 10:00:00 ET Message A\n" "2024-01-02 11:00:00 ET Message B" - expected_output = ( - "2024-01-01 10:00:00 ET Message A\n" "2024-01-02 11:00:00 ET Message B" - ) +def test_multiline_entries_rearrange_by_datetime_ascending(): + input_text = """ + ➡️ [2024-03-07 21:53:26 ET](https://example.com/link1) John Doe: Message one + ➡️ [2024-03-05 18:24:30 ET](https://example.com/link2) Jane Smith: Message two + """ + expected_output = "➡️ [2024-03-05 18:24:30 ET](https://example.com/link2) Jane Smith: Message two\n\n\n➡️ [2024-03-07 21:53:26 ET](https://example.com/link1) John Doe: Message one" assert ( - handle_slack_message_reactions.rearrange_by_datetime_ascending(input_text) + handle_slack_message_reactions.rearrange_by_datetime_ascending( + input_text + ).strip() == expected_output ) -def test_multiline_entries_rearrange_by_datetime_ascending(): - input_text = ( - "2024-01-01 10:00:00 ET Message A\nContinued\n" - "2024-01-02 11:00:00 ET Message B" - ) - expected_output = ( - "2024-01-01 10:00:00 ET Message A\nContinued\n" - "2024-01-02 11:00:00 ET Message B" +def test_rearrange_single_entry(): + input_text = "➡️ [2024-03-07 21:53:26 ET](https://example.com/link1) John Doe: Only one message" + expected_output = "➡️ [2024-03-07 21:53:26 ET](https://example.com/link1) John Doe: Only one message" + assert ( + handle_slack_message_reactions.rearrange_by_datetime_ascending( + input_text + ).strip() + == expected_output ) + + +def test_rearrange_no_entries(): + input_text = "" + expected_output = "" assert ( - handle_slack_message_reactions.rearrange_by_datetime_ascending(input_text) + handle_slack_message_reactions.rearrange_by_datetime_ascending( + input_text + ).strip() == expected_output ) def test_entries_out_of_order_rearrange_by_datetime_ascending(): - input_text = "2024-01-02 11:00:00 ET Message B\n" "2024-01-01 10:00:00 ET Message A" - expected_output = ( - "2024-01-01 10:00:00 ET Message A\n" "2024-01-02 11:00:00 ET Message B" - ) + input_text = """ + ➡️ [2024-03-07 11:00:00 ET](https://example.com/link1) John Doe: Message one + ➡️ [2024-03-07 10:00:00 ET](https://example.com/link2) Jane Smith: Message two + """ + expected_output = "➡️ [2024-03-07 10:00:00 ET](https://example.com/link2) Jane Smith: Message two\n\n\n➡️ [2024-03-07 11:00:00 ET](https://example.com/link1) John Doe: Message one" assert ( - handle_slack_message_reactions.rearrange_by_datetime_ascending(input_text) + handle_slack_message_reactions.rearrange_by_datetime_ascending( + input_text + ).strip() == expected_output ) def test_invalid_entries_rearrange_by_datetime_ascending(): - input_text = "Invalid Entry\n" "2024-01-01 10:00:00 ET Message A" - expected_output = "2024-01-01 10:00:00 ET Message A" + input_text = """ + ➡️ Invalid Entry + ➡️ [2024-03-07 10:00:00 ET](https://example.com/link2) Jane Smith: Message two + """ + expected_output = "➡️ [2024-03-07 10:00:00 ET](https://example.com/link2) Jane Smith: Message two\n" assert ( handle_slack_message_reactions.rearrange_by_datetime_ascending(input_text) == expected_output diff --git a/app/tests/modules/incident/test_incident.py b/app/tests/modules/incident/test_incident.py index 6317f6bc..f0bdc3a8 100644 --- a/app/tests/modules/incident/test_incident.py +++ b/app/tests/modules/incident/test_incident.py @@ -1046,6 +1046,42 @@ def test_handle_reaction_added_adding_new_message_to_timeline_user_handle(): mock_client.users_profile_get.assert_called_once() +def test_handle_reaction_added_returns_link(): + logger = MagicMock() + mock_client = MagicMock() + mock_client.conversations_info.return_value = {"channel": {"name": "incident-123"}} + mock_client.conversations_history.return_value = { + "ok": True, + "messages": [ + { + "type": "message", + "user": "U123ABC456", + "text": " says Sample test message", + "ts": "1512085950.000216", + } + ], + } + mock_client.chat_getPermalink.return_value = { + "ok": "true", + "channel": "C123456", + "permalink": "https://example.com", + } + body = { + "event": { + "reaction": "floppy_disk", + "item": {"channel": "C123456", "ts": "123456"}, + } + } + + incident.handle_reaction_added(mock_client, lambda: None, body, logger) + + # Make assertion that the function calls the correct functions + mock_client.conversations_history.assert_called_once() + mock_client.bookmarks_list.assert_called_once() + mock_client.users_profile_get.assert_called_once() + mock_client.chat_getPermalink.assert_called_once() + + def test_handle_reaction_removed_successful_message_removal(): # Mock the client and logger logger = MagicMock()