From ef49dc97ebacf1d1b08caf46af583f624a5331ce Mon Sep 17 00:00:00 2001
From: Sylvia McLaughlin <85905333+sylviamclaughlin@users.noreply.github.com>
Date: Fri, 8 Mar 2024 13:40:25 -0800
Subject: [PATCH] Floppy disk save emoji improvements (#424)

* Adding logic to add a link to the Slack message and an emoji at the begginng of each entry

* Formatting
---
 app/integrations/google_drive.py              | 189 ++++++++++++------
 .../handle_slack_message_reactions.py         |  62 ++----
 app/modules/incident/incident.py              |  31 ++-
 app/tests/intergrations/test_google_drive.py  |  86 ++++++++
 .../test_handle_slack_message_reactions.py    |  60 ++++--
 app/tests/modules/incident/test_incident.py   |  36 ++++
 6 files changed, 334 insertions(+), 130 deletions(-)

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<date>.+?) ET\]\((?P<url>.+?)\) (?P<name>.+?): (?P<message>.+)$"
+
+                # 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": "<U123ABC456> 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()