SlackUtilsApiModule

Slack-specific utilities for Cortex Assistant integration. Handles Slack Block Kit formatting, message parsing, and UI element creation.

python · ApiModules

Details

IDSlackUtilsApiModule
Languagepython
From Version8.14.0
Docker Imagedemisto/slackv3:1.0.0.5301537
import pytest
import json
import demistomock as demisto
from SlackUtilsApiModule import (
    SlackAssistantMessages,
    parse_md_to_rich_text_elements,
    create_rich_table_cell,
    parse_md_table_to_slack_table,
    process_md_text_part,
    format_md_to_slack_message,
    create_agent_selection_blocks,
    get_feedback_buttons_block,
    get_approval_buttons_block,
    get_feedback_modal,
    is_bot_mention,
    is_assistant_interactive_response,
    is_assistant_modal_submission,
    normalize_slack_message_from_user,
)
from CortexAssistantApiModule import AssistantActionIds, AssistantMessageType


# ============================================================================
# Test SlackAssistantMessages
# ============================================================================


def test_slack_assistant_messages_format_message_with_bot_tag():
    """
    Given:
        Message template with bot_tag placeholder and bot_id.
    When:
        Formatting message.
    Then:
        Replaces bot_tag with Slack mention format.
    """
    result = SlackAssistantMessages.format_message("Please contact {bot_tag} for help", bot_id="BOT123")
    assert result == "Please contact <@BOT123> for help"


def test_slack_assistant_messages_format_message_with_user_tag():
    """
    Given:
        Message template with locked_user_tag placeholder and user ID.
    When:
        Formatting message.
    Then:
        Replaces locked_user_tag with Slack mention format.
    """
    result = SlackAssistantMessages.format_message("Only {locked_user_tag} can respond", locked_user="USER123")
    assert result == "Only <@USER123> can respond"


def test_slack_assistant_messages_format_message_both_tags():
    """
    Given:
        Message template with both bot_tag and locked_user_tag.
    When:
        Formatting message.
    Then:
        Replaces both placeholders with Slack mentions.
    """
    result = SlackAssistantMessages.format_message(
        "{bot_tag} is locked to {locked_user_tag}", bot_id="BOT123", locked_user="USER123"
    )
    assert result == "<@BOT123> is locked to <@USER123>"


# ============================================================================
# Test parse_to_rich_text_elements
# ============================================================================


def test_parse_to_rich_text_elements_plain_text():
    """
    Given:
        Plain text without formatting.
    When:
        Parsing to rich text elements.
    Then:
        Returns single text element.
    """
    result = parse_md_to_rich_text_elements("Hello world")
    assert len(result) == 1
    assert result[0]["type"] == "text"
    assert result[0]["text"] == "Hello world"


def test_parse_to_rich_text_elements_bold():
    """
    Given:
        Text with bold markdown.
    When:
        Parsing to rich text elements.
    Then:
        Returns text element with bold style.
    """
    result = parse_md_to_rich_text_elements("**bold text**")
    assert len(result) == 1
    assert result[0]["type"] == "text"
    assert result[0]["text"] == "bold text"
    assert result[0]["style"]["bold"] is True


def test_parse_to_rich_text_elements_code():
    """
    Given:
        Text with inline code markdown.
    When:
        Parsing to rich text elements.
    Then:
        Returns text element with code style.
    """
    result = parse_md_to_rich_text_elements("`code here`")
    assert len(result) == 1
    assert result[0]["type"] == "text"
    assert result[0]["text"] == "code here"
    assert result[0]["style"]["code"] is True


def test_parse_to_rich_text_elements_link():
    """
    Given:
        Markdown link.
    When:
        Parsing to rich text elements.
    Then:
        Returns link element with text and url.
    """
    result = parse_md_to_rich_text_elements("[Click here](https://example.com)")
    assert len(result) == 1
    assert result[0]["type"] == "link"
    assert result[0]["text"] == "Click here"
    assert result[0]["url"] == "https://example.com"


def test_parse_to_rich_text_elements_url():
    """
    Given:
        Plain URL.
    When:
        Parsing to rich text elements.
    Then:
        Returns link element with URL as both text and url.
    """
    result = parse_md_to_rich_text_elements("https://example.com")
    assert len(result) == 1
    assert result[0]["type"] == "link"
    assert result[0]["url"] == "https://example.com"


def test_parse_to_rich_text_elements_mixed():
    """
    Given:
        Text with multiple formatting types.
    When:
        Parsing to rich text elements.
    Then:
        Returns multiple elements with correct styles.
    """
    result = parse_md_to_rich_text_elements("Hello **bold** and `code`")
    assert len(result) == 4
    assert result[0]["text"] == "Hello "
    assert result[1]["text"] == "bold"
    assert result[1]["style"]["bold"] is True
    assert result[2]["text"] == " and "
    assert result[3]["text"] == "code"
    assert result[3]["style"]["code"] is True


def test_parse_to_rich_text_elements_empty():
    """
    Given:
        Empty string.
    When:
        Parsing to rich text elements.
    Then:
        Returns single space element.
    """
    result = parse_md_to_rich_text_elements("")
    assert len(result) == 1
    assert result[0]["text"] == " "


def test_parse_md_empty_style_delimiters_kept_as_literal_text():
    """
    Given:
        Text containing markdown delimiters that wrap no content (e.g. a bare "__"
        segment, which the italic pattern matches with empty inner content).
    When:
        Parsing to rich text elements.
    Then:
        The delimiters are preserved as literal, unstyled text (no element is
        dropped and no element is emitted with an empty "text"). This prevents the
        Slack `invalid_blocks` error "missing required field: text", which occurs
        because the Slack SDK strips falsy fields (e.g. "text": "") on
        serialization, leaving an element with only "type"/"style".
    """
    result = parse_md_to_rich_text_elements("Action: Action__UpdateIssueFields")

    # No element may have an empty/missing text field.
    for element in result:
        assert element.get("text")

    # The literal underscores are preserved and carry no style.
    literal = next(element for element in result if "__" in element["text"])
    assert "style" not in literal

    # The surrounding content is retained.
    joined = "".join(element["text"] for element in result)
    assert joined == "Action: Action__UpdateIssueFields"


def test_parse_md_empty_bold_delimiters_kept_as_literal_text():
    """
    Given:
        Text containing empty bold delimiters ("****").
    When:
        Parsing to rich text elements.
    Then:
        The delimiters are kept as literal, unstyled text with a non-empty "text"
        field so Slack does not reject the block.
    """
    result = parse_md_to_rich_text_elements("before****after")

    for element in result:
        assert element.get("text")

    joined = "".join(element["text"] for element in result)
    assert joined == "before****after"


def test_parse_md_intraword_underscores_not_italicized():
    """
    Given:
        Text where underscores are adjacent to word characters, e.g. a token like
        "run__script" or "first_name". These must NOT be treated as italic
        emphasis (which would strip the underscores and, in the empty-content case,
        cause the Slack `invalid_attachments`/`invalid_blocks` error).
    When:
        Parsing to rich text elements.
    Then:
        The underscores are preserved literally, no element is italicized, and no
        element has an empty "text".
    """
    result = parse_md_to_rich_text_elements("Use the run__script and first_name now")

    for element in result:
        assert element.get("text")
        assert "style" not in element

    joined = "".join(element["text"] for element in result)
    assert joined == "Use the run__script and first_name now"


def test_parse_md_italic_with_surrounding_spaces_still_works():
    """
    Given:
        Legitimate italic markdown where the underscore delimiters are bounded by
        whitespace/edges (e.g. "_italic_" and "__also italic__").
    When:
        Parsing to rich text elements.
    Then:
        The emphasized text is italicized and the delimiters are removed.
    """
    result = parse_md_to_rich_text_elements("This is _italic_ and __also italic__ end")

    italic_texts = [element["text"] for element in result if element.get("style", {}).get("italic")]
    assert "italic" in italic_texts
    assert "also italic" in italic_texts


# ============================================================================
# Test create_rich_cell
# ============================================================================


def test_create_rich_cell_plain_text():
    """
    Given:
        Plain text without formatting.
    When:
        Creating rich cell.
    Then:
        Returns raw_text type cell.
    """
    result = create_rich_table_cell("Plain text")
    assert result["type"] == "raw_text"
    assert result["text"] == "Plain text"


def test_create_rich_cell_formatted_text():
    """
    Given:
        Text with bold formatting.
    When:
        Creating rich cell.
    Then:
        Returns raw_text type cell (Slack table cells don't support rich formatting).
    """
    result = create_rich_table_cell("**Bold text**")
    assert result["type"] == "raw_text"
    assert result["text"] == "**Bold text**"


def test_create_rich_cell_with_url_falls_back_to_raw_text():
    """
    Given:
        Cell text containing a URL.
    When:
        Creating rich cell.
    Then:
        Returns raw_text type (Slack table cells don't support link elements).
    """
    result = create_rich_table_cell("https://example.com/incident-view?caseId=1")
    assert result["type"] == "raw_text"
    assert result["text"] == "https://example.com/incident-view?caseId=1"


def test_create_rich_cell_with_markdown_link_falls_back_to_raw_text():
    """
    Given:
        Cell text containing a markdown link.
    When:
        Creating rich cell.
    Then:
        Returns raw_text type (Slack table cells don't support link elements).
    """
    result = create_rich_table_cell("[Click here](https://example.com)")
    assert result["type"] == "raw_text"
    assert result["text"] == "[Click here](https://example.com)"


def test_create_rich_cell_empty():
    """
    Given:
        Empty string.
    When:
        Creating rich cell.
    Then:
        Returns raw_text with single space.
    """
    result = create_rich_table_cell("")
    assert result["type"] == "raw_text"
    assert result["text"] == " "


# ============================================================================
# Test parse_md_table_to_slack_table
# ============================================================================


def test_parse_md_table_to_slack_table_simple():
    """
    Given:
        Simple markdown table with header and one row.
    When:
        Parsing to Slack table.
    Then:
        Returns table block with correct rows.
    """
    md_table = "|Header1|Header2|\n|---|---|\n|Cell1|Cell2|"
    result = parse_md_table_to_slack_table(md_table)

    assert result is not None
    assert result["type"] == "table"
    assert len(result["rows"]) == 2
    assert result["rows"][0][0]["text"] == "Header1"
    assert result["rows"][1][0]["text"] == "Cell1"


def test_parse_md_table_to_slack_table_multiple_rows():
    """
    Given:
        Markdown table with multiple data rows.
    When:
        Parsing to Slack table.
    Then:
        Returns table with all rows.
    """
    md_table = "|A|B|\n|---|---|\n|1|2|\n|3|4|"
    result = parse_md_table_to_slack_table(md_table)

    assert result is not None
    assert len(result["rows"]) == 3
    assert result["rows"][0][0]["text"] == "A"
    assert result["rows"][1][0]["text"] == "1"
    assert result["rows"][2][0]["text"] == "3"


def test_parse_md_table_to_slack_table_empty():
    """
    Given:
        Empty string.
    When:
        Parsing to Slack table.
    Then:
        Returns None.
    """
    result = parse_md_table_to_slack_table("")
    assert result is None


# ============================================================================
# Test process_text_part
# ============================================================================


def test_process_text_part_header():
    """
    Given:
        Text with markdown header.
    When:
        Processing text part.
    Then:
        Returns header block.
    """
    result = process_md_text_part("# My Header")
    assert len(result) == 1
    assert result[0]["type"] == "header"
    assert result[0]["text"]["text"] == "My Header"


def test_process_text_part_bullet_list():
    """
    Given:
        Text with bullet list items.
    When:
        Processing text part.
    Then:
        Returns rich_text block with bullet list.
    """
    result = process_md_text_part("- Item 1\n- Item 2")
    assert len(result) == 1
    assert result[0]["type"] == "rich_text"
    assert result[0]["elements"][0]["type"] == "rich_text_list"
    assert result[0]["elements"][0]["style"] == "bullet"


def test_process_text_part_numbered_list():
    """
    Given:
        Text with numbered list items.
    When:
        Processing text part.
    Then:
        Returns rich_text block with ordered list.
    """
    result = process_md_text_part("1. First\n2. Second")
    assert len(result) == 1
    assert result[0]["type"] == "rich_text"
    assert result[0]["elements"][0]["style"] == "ordered"


def test_process_text_part_code_block():
    """
    Given:
        Text with code block.
    When:
        Processing text part.
    Then:
        Returns rich_text block with preformatted element.
    """
    result = process_md_text_part("```python\nprint('hello')\n```")
    assert len(result) == 1
    assert result[0]["type"] == "rich_text"
    assert result[0]["elements"][0]["type"] == "rich_text_preformatted"


def test_process_text_part_paragraph():
    """
    Given:
        Plain paragraph text.
    When:
        Processing text part.
    Then:
        Returns rich_text block with section element.
    """
    result = process_md_text_part("This is a paragraph")
    assert len(result) == 1
    assert result[0]["type"] == "rich_text"
    assert result[0]["elements"][0]["type"] == "rich_text_section"


# ============================================================================
# Test prepare_slack_message
# ============================================================================


def test_prepare_slack_message_model_type(mocker):
    """
    Given:
        Model message type.
    When:
        Preparing Slack message.
    Then:
        Returns blocks without attachments.
    """
    blocks, attachments = format_md_to_slack_message("Hello", AssistantMessageType.MODEL.value)
    assert len(blocks) > 0
    assert len(attachments) == 0


def test_prepare_slack_message_step_type(mocker):
    """
    Given:
        Step message type.
    When:
        Preparing Slack message.
    Then:
        Returns empty blocks with gray attachment.
    """
    blocks, attachments = format_md_to_slack_message("Step 1", AssistantMessageType.STEP.value)
    assert len(blocks) == 0
    assert len(attachments) == 1
    assert attachments[0]["color"] == "#D1D2D3"


def test_prepare_slack_message_error_type(mocker):
    """
    Given:
        Error message type.
    When:
        Preparing Slack message.
    Then:
        Returns empty blocks with red attachment.
    """
    blocks, attachments = format_md_to_slack_message("Error", AssistantMessageType.ERROR.value)
    assert len(blocks) == 0
    assert len(attachments) == 1
    assert attachments[0]["color"] == "#FF0000"


def test_prepare_slack_message_invalid_type(mocker):
    """
    Given:
        Invalid message type.
    When:
        Preparing Slack message.
    Then:
        Raises ValueError.
    """
    mocker.patch.object(demisto, "error")
    with pytest.raises(ValueError) as exc_info:
        format_md_to_slack_message("Test", "invalid_type")
    assert "Invalid message_type" in str(exc_info.value)


def test_prepare_slack_message_empty():
    """
    Given:
        Empty message.
    When:
        Preparing Slack message.
    Then:
        Returns empty blocks and attachments.
    """
    blocks, attachments = format_md_to_slack_message("", AssistantMessageType.MODEL.value)
    assert blocks == []
    assert attachments == []


# ============================================================================
# Test create_agent_selection_blocks
# ============================================================================


def test_create_agent_selection_blocks_with_agents():
    """
    Given:
        List of agents with id and name.
    When:
        Creating agent selection blocks.
    Then:
        Returns blocks with dropdown and options.
    """
    agents = [{"id": "agent1", "name": "Security Analyst"}, {"id": "agent2", "name": "Incident Responder"}]
    result = create_agent_selection_blocks(agents)

    assert len(result) == 2
    assert result[0]["type"] == "section"
    assert result[1]["type"] == "actions"


def test_create_agent_selection_blocks_empty():
    """
    Given:
        Empty agents list.
    When:
        Creating agent selection blocks.
    Then:
        Returns empty list.
    """
    result = create_agent_selection_blocks([])
    assert result == []


# ============================================================================
# Test get_feedback_buttons_block
# ============================================================================


def test_get_feedback_buttons_block():
    """
    Given:
        Message ID.
    When:
        Creating feedback buttons block.
    Then:
        Returns block with positive and negative buttons.
    """
    result = get_feedback_buttons_block("msg123")

    assert result["type"] == "context_actions"
    assert result["elements"][0]["type"] == "feedback_buttons"
    assert result["elements"][0]["positive_button"]["value"] == "positive-msg123"
    assert result["elements"][0]["negative_button"]["value"] == "negative-msg123"


# ============================================================================
# Test get_approval_buttons_block
# ============================================================================


def test_get_approval_buttons_block():
    """
    Given:
        No parameters.
    When:
        Creating approval buttons block.
    Then:
        Returns blocks with header, divider, and action buttons.
    """
    result = get_approval_buttons_block()

    assert len(result) == 4
    assert result[0]["type"] == "header"
    assert result[1]["type"] == "divider"
    assert result[2]["type"] == "section"
    assert result[3]["type"] == "actions"


# ============================================================================
# Test get_feedback_modal
# ============================================================================


def test_get_feedback_modal():
    """
    Given:
        Message ID, channel ID, and thread timestamp.
    When:
        Creating feedback modal.
    Then:
        Returns View with checkboxes and text input.
    """
    result = get_feedback_modal("msg123", "channel123", "thread123")

    assert result.type == "modal"
    assert result.callback_id == AssistantActionIds.FEEDBACK_MODAL_CALLBACK_ID
    assert len(result.blocks) == 2

    assert result.private_metadata is not None
    metadata = json.loads(result.private_metadata)
    assert metadata["message_id"] == "msg123"
    assert metadata["channel_id"] == "channel123"


# ============================================================================
# Test is_bot_mention
# ============================================================================


def test_is_bot_mention_true():
    """
    Given:
        Text with bot mention and normal event.
    When:
        Checking if bot is mentioned.
    Then:
        Returns True.
    """
    result = is_bot_mention("<@BOT123> help", "BOT123", {})
    assert result is True


def test_is_bot_mention_false_no_mention():
    """
    Given:
        Text without bot mention.
    When:
        Checking if bot is mentioned.
    Then:
        Returns False.
    """
    result = is_bot_mention("Hello everyone", "BOT123", {})
    assert result is False


def test_is_bot_mention_false_channel_join():
    """
    Given:
        Text with bot mention but channel_join subtype.
    When:
        Checking if bot is mentioned.
    Then:
        Returns False.
    """
    result = is_bot_mention("<@BOT123> joined", "BOT123", {"subtype": "channel_join"})
    assert result is False


# ============================================================================
# Test is_assistant_interactive_response
# ============================================================================


def test_is_assistant_interactive_response_true():
    """
    Given:
        Actions with valid assistant action_id.
    When:
        Checking if assistant interactive response.
    Then:
        Returns True.
    """
    actions = [{"action_id": AssistantActionIds.FEEDBACK.value}]
    result = is_assistant_interactive_response(actions)
    assert result is True


def test_is_assistant_interactive_response_false():
    """
    Given:
        Actions with non-assistant action_id.
    When:
        Checking if assistant interactive response.
    Then:
        Returns False.
    """
    actions = [{"action_id": "some_other_action"}]
    result = is_assistant_interactive_response(actions)
    assert result is False


def test_is_assistant_interactive_response_empty():
    """
    Given:
        Empty actions list.
    When:
        Checking if assistant interactive response.
    Then:
        Returns False.
    """
    result = is_assistant_interactive_response([])
    assert result is False


# ============================================================================
# Test is_assistant_modal_submission
# ============================================================================


def test_is_assistant_modal_submission_true():
    """
    Given:
        Interactive data type with feedback modal callback_id.
    When:
        Checking if assistant modal submission.
    Then:
        Returns True.
    """
    view = {"callback_id": AssistantActionIds.FEEDBACK_MODAL_CALLBACK_ID}
    result = is_assistant_modal_submission("interactive", view)
    assert result is True


def test_is_assistant_modal_submission_false():
    """
    Given:
        Interactive data type with different callback_id.
    When:
        Checking if assistant modal submission.
    Then:
        Returns False.
    """
    view = {"callback_id": "other_modal"}
    result = is_assistant_modal_submission("interactive", view)
    assert result is False


def test_is_assistant_modal_submission_wrong_type():
    """
    Given:
        Non-interactive data type.
    When:
        Checking if assistant modal submission.
    Then:
        Returns False.
    """
    result = is_assistant_modal_submission("message", {})
    assert result is False


# ============================================================================
def test_normalize_slack_message_from_user_html_entities():
    """
    Given:
        Text with HTML entities.
    When:
        Normalizing Slack message.
    Then:
        Decodes HTML entities.
    """
    result = normalize_slack_message_from_user("&lt;test&gt; &amp; &quot;quoted&quot;")
    assert result == '<test> & "quoted"'


def test_normalize_slack_message_from_user_preserves_mentions():
    """
    Given:
        Text with Slack user mentions.
    When:
        Normalizing Slack message.
    Then:
        Preserves mention format.
    """
    result = normalize_slack_message_from_user("<@U12345> hello")
    assert result == "<@U12345> hello"


def test_normalize_slack_message_from_user_empty():
    """
    Given:
        Empty string.
    When:
        Normalizing Slack message.
    Then:
        Returns empty string.
    """
    result = normalize_slack_message_from_user("")
    assert result == ""


def test_process_text_part_divider():
    """
    Given:
        Text with horizontal rule (---).
    When:
        Processing text part.
    Then:
        Returns divider block.
    """
    result = process_md_text_part("---")
    assert len(result) == 1
    assert result[0]["type"] == "divider"


def test_process_text_part_header_with_bold():
    """
    Given:
        Header with bold markdown inside.
    When:
        Processing text part.
    Then:
        Returns header block with bold formatting removed.
    """
    result = process_md_text_part("### **Case Details**")
    assert len(result) == 1
    assert result[0]["type"] == "header"
    assert result[0]["text"]["text"] == "Case Details"


def test_process_text_part_header_with_multiple_formatting():
    """
    Given:
        Header with multiple markdown formats.
    When:
        Processing text part.
    Then:
        Returns header block with all formatting removed.
    """
    result = process_md_text_part("## **Bold** and `code` and _italic_")
    assert len(result) == 1
    assert result[0]["type"] == "header"
    assert result[0]["text"]["text"] == "Bold and code and italic"


def test_process_text_part_complex_with_dividers():
    """
    Given:
        Text with headers, dividers, and content.
    When:
        Processing text part.
    Then:
        Returns blocks in correct order with dividers.
    """
    text = "### **Case Details**\n\n---\n\nSome content"
    result = process_md_text_part(text)

    assert len(result) == 3
    assert result[0]["type"] == "header"
    assert result[0]["text"]["text"] == "Case Details"
    assert result[1]["type"] == "divider"
    assert result[2]["type"] == "rich_text"