SlackUtilsApiModule

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

python · ApiModules

Source

import re
import json
from typing import Any
from slack_sdk.models.blocks import (
    SectionBlock,
    ActionsBlock,
    HeaderBlock,
    DividerBlock,
    StaticSelectElement,
    Option,
    ConfirmObject,
    ButtonElement,
    PlainTextObject,
    MarkdownTextObject,
    ContextBlock,
    RichTextBlock,
    RichTextListElement,
    RichTextSectionElement,
    InputBlock,
    CheckboxesElement,
    PlainTextInputElement,
)
from slack_sdk.models.views import View
import demistomock as demisto  # noqa: F401
from CommonServerPython import *  # noqa: F401
from CortexAssistantApiModule import *


class SlackAssistantMessages(AssistantMessages):
    """
    Slack-specific messages and UI elements for Assistant.
    Extends the base AssistantMessages with Slack-specific formatting.
    """

    # Step/Plan display (Slack-specific with emoji)
    PLAN_LABEL = "*Plan*"
    PLAN_ICON = ":clipboard:"

    @classmethod
    def format_message(cls, message_template: str, bot_id: str = "", locked_user: str = "") -> str:
        """
        Formats a message template with Slack-specific bot and user tags.

        Args:
            message_template: The message template
            bot_id: The bot user ID
            locked_user: The locked user ID

        Returns:
            Formatted message with proper Slack tags
        """
        message = message_template
        if "{bot_tag}" in message and bot_id:
            message = message.replace("{bot_tag}", f"<@{bot_id}>")
        if "{locked_user_tag}" in message and locked_user:
            message = message.replace("{locked_user_tag}", f"<@{locked_user}>")
        return message


# Slack requires at least one element in a rich_text_section; a single space is the minimal placeholder.
EMPTY_RICH_TEXT_ELEMENTS: list[dict] = [{"type": "text", "text": " "}]


def parse_md_to_rich_text_elements(text: str) -> list[dict]:
    """
    Parses markdown-formatted text and converts it to Slack rich_text elements.
    https://docs.slack.dev/reference/block-kit/blocks/rich-text-block/

    Supported markdown syntax:
    - Bold: **text** → {"type": "text", "text": "text", "style": {"bold": True}}
    - Italic: _text_ or __text__ → {"type": "text", "text": "text", "style": {"italic": True}}
    - Strikethrough: ~~text~~ → {"type": "text", "text": "text", "style": {"strike": True}}
    - Inline code: `text` → {"type": "text", "text": "text", "style": {"code": True}}
    - Links: [text](url) → {"type": "link", "text": "text", "url": "url"}
    - URLs: https://example.com → {"type": "link", "text": "url", "url": "url"}

    Algorithm:
    1. Build regex pattern that matches all supported markdown syntax
    2. Split text by pattern to get alternating plain text and formatted parts
    3. For each part, determine its type and create appropriate Slack element
    4. Return list of elements (or single space element if empty)

    Args:
        text: Markdown-formatted text to parse

    Returns:
        List of Slack rich_text element dictionaries

    Example:
        >>> parse_to_rich_text_elements("Hello **world** and `code`")
        [
            {"type": "text", "text": "Hello "},
            {"type": "text", "text": "world", "style": {"bold": True}},
            {"type": "text", "text": " and "},
            {"type": "text", "text": "code", "style": {"code": True}}
        ]
    """
    if not text:
        return list(EMPTY_RICH_TEXT_ELEMENTS)

    # Define regex patterns for each markdown syntax
    # Order matters: more specific patterns first
    LINK_PATTERN = r"\[.*?\]\(.*?\)"  # [text](url)
    URL_PATTERN = r'https?://[^\s<>"]+'  # https://example.com
    BOLD_PATTERN = r"\*\*.*?\*\*"  # **text**
    CODE_PATTERN = r"`[^`]+`"  # `text`
    STRIKE_PATTERN = r"~~.*?~~"  # ~~text~~
    ITALIC_DOUBLE_PATTERN = r"__.*?__"  # __text__
    ITALIC_SINGLE_PATTERN = r"_.*?_"  # _text_

    # Combine all patterns with alternation (|) - each in its own capture group
    # This allows us to identify which pattern matched
    combined_pattern = (
        f"({LINK_PATTERN})|({URL_PATTERN})|({BOLD_PATTERN})|({CODE_PATTERN})|"
        f"({STRIKE_PATTERN})|({ITALIC_DOUBLE_PATTERN})|({ITALIC_SINGLE_PATTERN})"
    )

    # Split text by pattern - results in alternating plain text and matched patterns
    parts = re.split(combined_pattern, text)

    elements: list[dict] = []
    for part in parts:
        if not part:
            continue

        # Check if this part matches a specific pattern and create appropriate element

        # Markdown link: [text](url)
        if link_match := re.match(r"\[(.*?)\]\((.*?)\)", part):
            elements.append({"type": "link", "text": link_match.group(1), "url": link_match.group(2)})
            continue

        # Plain URL: https://example.com
        if url_match := re.match(URL_PATTERN, part):
            url = url_match.group(0)
            elements.append({"type": "link", "text": url, "url": url})
            continue

        # For styled text, extract content and determine style
        style = {}
        content = part

        if re.match(BOLD_PATTERN, part):
            content = part[2:-2]  # Remove ** from both sides
            style["bold"] = True
        elif re.match(CODE_PATTERN, part):
            content = part[1:-1]  # Remove ` from both sides
            style["code"] = True
        elif re.match(STRIKE_PATTERN, part):
            content = part[2:-2]  # Remove ~~ from both sides
            style["strike"] = True
        elif re.match(ITALIC_DOUBLE_PATTERN, part):
            content = part[2:-2]  # Remove __ from both sides
            style["italic"] = True
        elif re.match(ITALIC_SINGLE_PATTERN, part):
            content = part[1:-1]  # Remove _ from both sides
            style["italic"] = True

        # Create text element with optional style
        element = {"type": "text", "text": content}
        if style:
            element["style"] = style

        elements.append(element)

    return elements if elements else list(EMPTY_RICH_TEXT_ELEMENTS)


def create_rich_table_cell(text: str) -> dict:
    """
    Creates a Slack table cell as raw_text.

    Note: Slack table cells do NOT support rich_text_section elements
    (links, bold, italic, code, etc. all cause invalid_blocks errors).
    We always use raw_text format for table cells.

    Args:
        text: Cell content (plain text)

    Returns:
        Slack table cell dictionary: {"type": "raw_text", "text": "..."}

    Example:
        >>> create_rich_cell("Plain text")
        {"type": "raw_text", "text": "Plain text"}
    """
    return {"type": "raw_text", "text": text if text else " "}


def parse_md_table_to_slack_table(md_text: str) -> dict | None:
    """
    Converts markdown table to Slack table block.

    Markdown table format:
    ```
    | Header 1 | Header 2 |
    |----------|----------|
    | Cell 1   | Cell 2   |
    ```

    Algorithm:
    1. Split table into lines
    2. Skip separator lines (|---|---|)
    3. For each data line:
       - Split by | delimiter
       - Remove leading/trailing | if present
       - Create rich cell for each column
    4. Return Slack table block with all rows

    Args:
        md_text: Markdown table text (must include header separator line)

    Returns:
        Slack table block dictionary, or None if table is empty/invalid

    Example:
        >>> parse_md_table_to_slack_table("|A|B|\\n|---|---|\\n|1|2|")
        {
            "type": "table",
            "column_settings": [{"is_wrapped": True}],
            "rows": [[{"type": "raw_text", "text": "A"}, ...], ...]
        }
    """
    if not md_text or not md_text.strip():
        return None

    rows = [row.strip() for row in md_text.strip().split("\n")]
    if not rows:
        return None

    table_rows = []
    SEPARATOR_PATTERN = r"^[\s|:-]+$"  # Matches rows like |---|---| or | --- | --- |

    for row in rows:
        # Skip separator rows (|---|---|)
        if re.match(SEPARATOR_PATTERN, row):
            continue

        # Split row by | delimiter
        raw_cells = [cell.strip() for cell in row.split("|")]

        # Remove empty cells from leading/trailing |
        if row.startswith("|"):
            raw_cells.pop(0)
        if row.endswith("|"):
            raw_cells.pop()

        if not raw_cells:
            continue

        # Convert each cell to Slack table cell format
        table_rows.append([create_rich_table_cell(cell) for cell in raw_cells])

    if not table_rows:
        return None

    return {
        "type": "table",
        "column_settings": [{"is_wrapped": True}],  # Allow text wrapping in cells
        "rows": table_rows,
    }


def process_md_text_part(text: str) -> list[dict]:
    """
    Processes markdown text and converts it to Slack Block Kit blocks.

    Supported markdown elements:
    - Code blocks: ```python\\ncode\\n``` → rich_text_preformatted block
    - Headers: # Header → header block
    - Bullet lists: - item or * item → rich_text list (bullet style)
    - Numbered lists: 1. item → rich_text list (ordered style)
    - Paragraphs: Plain text → rich_text section

    Algorithm:
    1. Extract code blocks (```...```) and replace with placeholders
    2. Process line by line:
       - Headers (# text) → create header block
       - List items (- or * or 1.) → accumulate into list
       - Empty lines → flush current paragraph/list
       - Code block placeholders → create preformatted block
       - Other lines → accumulate into paragraph
    3. Flush any remaining paragraph/list at end

    Args:
        text: Markdown-formatted text to process

    Returns:
        List of Slack block dictionaries (header, rich_text, etc.)

    Example:
        >>> process_text_part("# Title\\n\\n- Item 1\\n- Item 2\\n\\nParagraph")
        [
            {"type": "header", "text": {"type": "plain_text", "text": "Title"}},
            {"type": "rich_text", "elements": [{"type": "rich_text_list", ...}]},
            {"type": "rich_text", "elements": [{"type": "rich_text_section", ...}]}
        ]
    """
    sub_blocks = []

    # Step 1: Extract code blocks and replace with placeholders
    # This prevents code content from being parsed as markdown
    CODE_BLOCK_PATTERN = r"```(\w+)?\n(.*?)\n```"
    code_blocks: list[dict[str, Any]] = []

    def save_code_block(match):
        """Saves code block and returns placeholder"""
        language = match.group(1) or ""
        code_content = match.group(2)
        placeholder = f"__CODE_BLOCK_{len(code_blocks)}__"
        code_blocks.append({"language": language, "content": code_content})
        return placeholder

    text = re.sub(CODE_BLOCK_PATTERN, save_code_block, text, flags=re.DOTALL)

    # Step 2: Process text line by line
    lines = text.split("\n")
    current_paragraph: list[str] = []
    current_list_items: list[str] = []
    current_list_style = "bullet"  # "bullet" or "ordered"

    def flush_list():
        """Converts accumulated list items to Slack rich_text list block"""
        if current_list_items:
            sub_blocks.append(
                RichTextBlock(
                    elements=[
                        RichTextListElement(
                            style=current_list_style,  # type: ignore[arg-type]
                            elements=[
                                RichTextSectionElement(elements=parse_md_to_rich_text_elements(item))  # type: ignore[arg-type]
                                for item in current_list_items
                            ],
                        )
                    ]
                ).to_dict()
            )
            current_list_items.clear()

    def flush_paragraph():
        """Converts accumulated paragraph lines to Slack rich_text section block"""
        if current_paragraph:
            para_text = "\n".join(current_paragraph).strip()
            if para_text:
                sub_blocks.append(
                    RichTextBlock(
                        elements=[
                            RichTextSectionElement(elements=parse_md_to_rich_text_elements(para_text))  # type: ignore[arg-type]
                        ]
                    ).to_dict()
                )
            current_paragraph.clear()

    # Regex patterns for line types
    CODE_BLOCK_PLACEHOLDER_PATTERN = r"__CODE_BLOCK_(\d+)__"
    HEADER_PATTERN = r"^(#{1,6})\s+(.+)"  # # Header or ## Header, etc.
    DIVIDER_PATTERN = r"^---+$"  # --- or ---- (horizontal rule)
    BULLET_LIST_PATTERN = r"^[-*]\s+(.+)"  # - item or * item
    NUMBERED_LIST_PATTERN = r"^(\d+)\.\s+(.+)"  # 1. item

    for line in lines:
        stripped_line = line.strip()

        # Check for code block placeholder
        if code_block_match := re.match(CODE_BLOCK_PLACEHOLDER_PATTERN, stripped_line):
            flush_paragraph()
            flush_list()
            block_index = int(code_block_match.group(1))
            if block_index < len(code_blocks):
                code_block = code_blocks[block_index]
                # Create preformatted block for code (inline display)
                sub_blocks.append(
                    {
                        "type": "rich_text",
                        "elements": [
                            {
                                "type": "rich_text_preformatted",
                                "elements": [{"type": "text", "text": code_block["content"]}],
                            }
                        ],
                    }
                )
            continue

        # Empty line - flush current paragraph/list
        if not stripped_line:
            flush_paragraph()
            flush_list()
            continue

        # Check for horizontal rule / divider (---)
        if re.match(DIVIDER_PATTERN, stripped_line):
            flush_paragraph()
            flush_list()
            sub_blocks.append(DividerBlock().to_dict())
            continue

        # Check line type and process accordingly
        if header_match := re.match(HEADER_PATTERN, stripped_line):
            # Header line (# text)
            flush_paragraph()
            flush_list()
            header_content = header_match.group(2)
            # Remove markdown formatting from header (e.g., **bold**)
            header_content = re.sub(r"\*\*(.+?)\*\*", r"\1", header_content)  # Remove **bold**
            header_content = re.sub(r"__(.+?)__", r"\1", header_content)  # Remove __italic__
            header_content = re.sub(r"_(.+?)_", r"\1", header_content)  # Remove _italic_
            header_content = re.sub(r"`(.+?)`", r"\1", header_content)  # Remove `code`
            sub_blocks.append({"type": "header", "text": {"type": "plain_text", "text": header_content, "emoji": True}})

        elif bullet_list_match := re.match(BULLET_LIST_PATTERN, stripped_line):
            # Bullet list item (- item or * item)
            flush_paragraph()
            # Switch to bullet list if currently in ordered list
            if current_list_items and current_list_style != "bullet":
                flush_list()
            current_list_style = "bullet"
            current_list_items.append(bullet_list_match.group(1))

        elif numbered_list_match := re.match(NUMBERED_LIST_PATTERN, stripped_line):
            # Numbered list item (1. item)
            flush_paragraph()
            # Switch to ordered list if currently in bullet list
            if current_list_items and current_list_style != "ordered":
                flush_list()
            current_list_style = "ordered"
            current_list_items.append(numbered_list_match.group(2))

        else:
            # Regular paragraph line
            if current_list_items:
                flush_list()
            current_paragraph.append(line)

    # Flush any remaining content
    flush_paragraph()
    flush_list()

    return sub_blocks


def format_md_to_slack_message(message: str, message_type: str) -> tuple[list, list]:
    """
    Converts markdown-formatted message to Slack Block Kit format.

    This is the main entry point for converting Assistant responses to Slack messages.
    Handles different message types with appropriate styling:
    - Step/Thought: Gray attachment with "Plan" header
    - Error: Red attachment with error icon
    - Model/Final: Standard blocks with no attachment

    Processing flow:
    1. Validate message_type using AssistantMessageType enum
    2. Split message into tables and text parts
    3. Convert tables to Slack table blocks
    4. Convert text to Slack rich_text blocks (headers, lists, paragraphs, code)
    5. Wrap in attachments if needed (step/error types)

    Args:
        message: Markdown-formatted message text
        message_type: Message type from AssistantMessageType enum

    Returns:
        Tuple of (blocks, attachments):
        - blocks: List of Slack block dictionaries (empty for step/error types)
        - attachments: List of attachment dictionaries (used for step/error types)

    Raises:
        ValueError: If message_type is not a valid AssistantMessageType value

    Example:
        >>> prepare_slack_message("# Title\\n\\n- Item 1", "model")
        ([{"type": "header", ...}, {"type": "rich_text", ...}], [])

        >>> prepare_slack_message("Step 1", "step")
        ([], [{"color": "#D1D2D3", "blocks": [...]}])
    """
    # Validate message_type using enum
    try:
        AssistantMessageType(message_type)
    except ValueError:
        error_msg = (
            f"Invalid message_type: '{message_type}'. " f"Must be one of: {', '.join([t.value for t in AssistantMessageType])}"
        )
        demisto.error(error_msg)
        raise ValueError(error_msg)

    if not message:
        return [], []

    blocks = []
    attachments = []

    # Step 1: Split message into tables and text parts
    # Tables are identified by markdown table syntax: |col1|col2|\n|---|---|\n|val1|val2|
    TABLE_REGEX = r"(\|[^\n]+\|\r?\n\|[\s|:-]+\|\r?\n(?:\|[^\n]+\|\r?\n?)+)"
    parts = re.split(TABLE_REGEX, message)

    # Step 2: Process each part
    for part in parts:
        if not part:
            continue

        if re.match(TABLE_REGEX, part):
            # This part is a markdown table
            table_block = parse_md_table_to_slack_table(part)
            if table_block:
                blocks.append(table_block)
        else:
            # This part is regular text (may contain headers, lists, code blocks, etc.)
            if part.strip():
                blocks.extend(process_md_text_part(part))

    # Step 3: Wrap blocks in attachments based on message type

    if AssistantMessageType.is_step_type(message_type):
        # Step/Thought messages: wrap in gray attachment with "Plan" header
        attachment_blocks = [
            ContextBlock(
                elements=[MarkdownTextObject(text=f"{SlackAssistantMessages.PLAN_ICON} {SlackAssistantMessages.PLAN_LABEL}")]
            ).to_dict()
        ] + blocks

        attachments = [
            {
                "color": "#D1D2D3",  # Light gray border
                "blocks": attachment_blocks,
            }
        ]
        return [], attachments

    if AssistantMessageType.is_error_type(message_type):
        # Error messages: wrap in red attachment with error icon
        attachments = [
            {
                "color": "#FF0000",  # Red border
                "blocks": [ContextBlock(elements=[MarkdownTextObject(text=":x: *Error*")]).to_dict()] + blocks,
            }
        ]
        return [], attachments

    # Model/Final responses: return blocks directly (no attachment)
    return blocks, attachments


def prepare_slack_merged_step_messages(step_contents: list[str]) -> tuple[list, list]:
    """
    Merges multiple step-type message contents into a single Slack attachment
    with divider blocks separating each step.

    Each step's content is processed through process_text_part() for full
    markdown support (headers, lists, code blocks, etc.), and a DividerBlock
    is inserted between consecutive steps.

    Args:
        step_contents: List of step message content strings to merge.

    Returns:
        Tuple of (blocks, attachments):
        - blocks: Always empty (step messages use attachments)
        - attachments: Single attachment with merged step blocks and dividers

    Example:
        >>> prepare_slack_merged_step_messages(["Step 1 content", "Step 2 content"])
        ([], [{"color": "#D1D2D3", "blocks": [
            <Plan header>,
            <Step 1 blocks>,
            <divider>,
            <Step 2 blocks>
        ]}])
    """
    if not step_contents:
        return [], []

    # Start with the Plan header
    merged_blocks: list[dict] = [
        ContextBlock(
            elements=[MarkdownTextObject(text=f"{SlackAssistantMessages.PLAN_ICON} {SlackAssistantMessages.PLAN_LABEL}")]
        ).to_dict()
    ]

    for i, content in enumerate(step_contents):
        # Add divider between steps (not before the first one)
        if i > 0:
            merged_blocks.append(DividerBlock().to_dict())

        # Process each step's content through the full markdown pipeline
        step_blocks = process_md_text_part(content)
        merged_blocks.extend(step_blocks)

    attachments = [
        {
            "color": "#D1D2D3",  # Light gray border (same as single step)
            "blocks": merged_blocks,
        }
    ]

    return [], attachments


def create_agent_selection_blocks(agents: list[dict]) -> list:
    """
    Creates Slack blocks for agent selection dropdown with confirmation dialog.

    Args:
        agents: List of agent dictionaries containing 'id' and 'name' fields

    Returns:
        List of Slack blocks with agent dropdown and confirmation
    """
    dropdown_options = [
        Option(
            text=PlainTextObject(text=agent.get("name", agent.get("id", ""))),
            value=f"{AssistantActionIds.AGENT_SELECTION_VALUE_PREFIX.value}{agent.get('id', '')}",
        )
        for agent in agents
    ]

    if not dropdown_options:
        return []

    return [
        SectionBlock(text=MarkdownTextObject(text=AssistantMessages.AGENT_SELECTION_PROMPT)).to_dict(),
        ActionsBlock(
            elements=[
                StaticSelectElement(
                    placeholder=PlainTextObject(text=AssistantMessages.AGENT_SELECTION_PLACEHOLDER),
                    action_id=AssistantActionIds.AGENT_SELECTION.value,
                    options=dropdown_options,
                    confirm=ConfirmObject(
                        title=PlainTextObject(text=AssistantMessages.AGENT_SELECTION_CONFIRM_TITLE),
                        text=MarkdownTextObject(text=AssistantMessages.AGENT_SELECTION_CONFIRM_TEXT),
                        confirm=PlainTextObject(text=AssistantMessages.AGENT_SELECTION_CONFIRM_BUTTON),
                        deny=PlainTextObject(text=AssistantMessages.AGENT_SELECTION_DENY_BUTTON),
                    ),
                )
            ],
        ).to_dict(),
    ]


def get_feedback_buttons_block(message_id: str) -> dict:
    """
    Creates a feedback buttons block for AI responses.

    Args:
        message_id: The message ID for tracking feedback

    Returns:
        Slack block with Good/Bad feedback buttons
        Value format: "positive-message_id" or "negative-message_id"
    """
    return {
        "type": "context_actions",
        "elements": [
            {
                "type": "feedback_buttons",
                "action_id": AssistantActionIds.FEEDBACK.value,
                "positive_button": {
                    "text": {"type": "plain_text", "text": AssistantMessages.FEEDBACK_GOOD_BUTTON},
                    "value": f"positive-{message_id}",
                    "accessibility_label": AssistantMessages.FEEDBACK_GOOD_ACCESSIBILITY,
                },
                "negative_button": {
                    "text": {"type": "plain_text", "text": AssistantMessages.FEEDBACK_BAD_BUTTON},
                    "value": f"negative-{message_id}",
                    "accessibility_label": AssistantMessages.FEEDBACK_BAD_ACCESSIBILITY,
                },
            }
        ],
    }


def get_approval_buttons_block() -> list[dict]:
    """
    Creates approval UI blocks for sensitive actions with warning header and Proceed/Cancel buttons.
    Includes confirmation dialog for extra safety.

    Returns:
        List of Slack blocks with warning header and approval buttons with confirmation
    """
    return [
        HeaderBlock(
            text=PlainTextObject(text=AssistantMessages.APPROVAL_HEADER, emoji=True),
        ).to_dict(),
        DividerBlock().to_dict(),
        SectionBlock(text=MarkdownTextObject(text=AssistantMessages.APPROVAL_PROMPT)).to_dict(),
        ActionsBlock(
            elements=[
                ButtonElement(
                    text=PlainTextObject(text=AssistantMessages.APPROVAL_PROCEED_BUTTON),
                    style="primary",
                    action_id=AssistantActionIds.APPROVAL_YES.value,
                    confirm=ConfirmObject(
                        title=PlainTextObject(text=AssistantMessages.APPROVAL_CONFIRM_TITLE),
                        text=MarkdownTextObject(text=AssistantMessages.APPROVAL_CONFIRM_TEXT),
                        confirm=PlainTextObject(text=AssistantMessages.APPROVAL_CONFIRM_BUTTON),
                        deny=PlainTextObject(text=AssistantMessages.APPROVAL_DENY_BUTTON),
                    ),
                ),
                ButtonElement(
                    text=PlainTextObject(text=AssistantMessages.APPROVAL_CANCEL_BUTTON),
                    style="danger",
                    action_id=AssistantActionIds.APPROVAL_NO.value,
                ),
            ],
        ).to_dict(),
    ]


def get_feedback_modal(message_id: str, channel_id: str, thread_ts: str) -> View:
    """
    Creates a Slack feedback modal view using SDK classes.

    Args:
        message_id: The message ID for tracking
        channel_id: The channel ID
        thread_ts: The thread timestamp

    Returns:
        Slack View object for the feedback modal
    """
    return View(
        type="modal",
        callback_id=AssistantActionIds.FEEDBACK_MODAL_CALLBACK_ID.value,
        title=PlainTextObject(text=AssistantMessages.FEEDBACK_MODAL_TITLE),
        submit=PlainTextObject(text=AssistantMessages.FEEDBACK_MODAL_SUBMIT),
        close=PlainTextObject(text=AssistantMessages.FEEDBACK_MODAL_CANCEL),
        blocks=[
            InputBlock(
                block_id=AssistantActionIds.FEEDBACK_MODAL_QUICK_BLOCK_ID.value,
                label=PlainTextObject(text=AssistantMessages.FEEDBACK_MODAL_QUICK_LABEL),
                element=CheckboxesElement(
                    action_id=AssistantActionIds.FEEDBACK_MODAL_CHECKBOXES_ACTION_ID.value,
                    options=[
                        Option(
                            text=PlainTextObject(text=AssistantMessages.FEEDBACK_OPTION_NO_ANSWER),
                            value=AssistantMessages.FEEDBACK_OPTION_NO_ANSWER,
                        ),
                        Option(
                            text=PlainTextObject(text=AssistantMessages.FEEDBACK_OPTION_FACTUALLY_INCORRECT),
                            value=AssistantMessages.FEEDBACK_OPTION_FACTUALLY_INCORRECT,
                        ),
                        Option(
                            text=PlainTextObject(text=AssistantMessages.FEEDBACK_OPTION_ANSWERED_ANOTHER),
                            value=AssistantMessages.FEEDBACK_OPTION_ANSWERED_ANOTHER,
                        ),
                        Option(
                            text=PlainTextObject(text=AssistantMessages.FEEDBACK_OPTION_PARTIALLY_HELPFUL),
                            value=AssistantMessages.FEEDBACK_OPTION_PARTIALLY_HELPFUL,
                        ),
                        Option(
                            text=PlainTextObject(text=AssistantMessages.FEEDBACK_OPTION_UNHELPFUL),
                            value=AssistantMessages.FEEDBACK_OPTION_UNHELPFUL,
                        ),
                    ],
                ),
                optional=False,
            ),
            InputBlock(
                block_id=AssistantActionIds.FEEDBACK_MODAL_TEXT_BLOCK_ID.value,
                label=PlainTextObject(text=AssistantMessages.FEEDBACK_MODAL_ADDITIONAL_LABEL),
                element=PlainTextInputElement(
                    action_id=AssistantActionIds.FEEDBACK_MODAL_TEXT_INPUT_ACTION_ID.value,
                    multiline=True,
                    placeholder=PlainTextObject(text=AssistantMessages.FEEDBACK_MODAL_ADDITIONAL_PLACEHOLDER),
                ),
                optional=False,
            ),
        ],
        private_metadata=json.dumps(
            {
                "message_id": message_id,
                "channel_id": channel_id,
                "thread_ts": thread_ts,
            }
        ),
    )


def is_bot_mention(text: str, bot_id: str, event: dict[str, Any]) -> bool:
    """
    Checks if the message directly mentions the bot

    Args:
        text: The text content of the message
        bot_id: The bot user ID
        event: The event dictionary

    Returns:
        bool: Indicates if bot was directly mentioned
    """
    return f"<@{bot_id}>" in text and event.get("subtype", "") != "channel_join"


def is_assistant_interactive_response(actions: list) -> bool:
    """
    Check if received action is an assistant interactive response that requires special handling
    (agent selection, sensitive action approval, or feedback)
    """
    if actions:
        action = actions[0]
        # Check action_id using enum values
        action_id = action.get("action_id", "")
        valid_action_ids = {e.value for e in AssistantActionIds}
        if action_id in valid_action_ids:
            return True

    return False


def is_assistant_modal_submission(data_type: str, view: dict) -> bool:
    """
    Check if received event is an assistant modal submission (e.g., feedback modal)

    Args:
        data_type: The type of the Slack event
        view: The view payload from Slack

    Returns:
        True if this is an assistant modal submission, False otherwise
    """
    if data_type == "interactive":
        callback_id = view.get("callback_id", "")
        return callback_id == AssistantActionIds.FEEDBACK_MODAL_CALLBACK_ID
    return False


def normalize_slack_message_from_user(text: str) -> str:
    """
    Normalizes Slack message from user for backend processing.
    Decodes HTML entities but preserves Slack-specific syntax like user mentions and channel references.

    Decodes:
    - All HTML entities using html.unescape() (e.g., > -> >, < -> <, & -> &, " -> ", etc.)

    Preserves:
    - User mentions: <@U12345>
    - Channel references: <#C12345>
    - Slack links: <url|text> or <url>
    - Bullet points (- or * at start of line)
    - Numbered lists (1. 2. etc.)
    - Plain text structure

    Args:
        text: The Slack message text with Slack formatting

    Returns:
        Normalized text suitable for backend processing
    """
    if not text:
        return text

    # Decode all HTML entities (Slack uses these for special characters)
    import html

    text = html.unescape(text)

    # Note: We preserve Slack syntax like <@U12345>, <#C12345>, and <url|text>
    # The backend should handle these appropriately

    return text


async def handle_assistant_modal_submission(view: dict, user_id: str, user_email: str, handler: AssistantMessagingHandler):
    """
    Handles Assistant modal submissions (e.g., negative feedback with checkboxes and text).
    Extracts Slack-specific data and delegates to the handler.
    Uses AssistantActionIds enum for block and action IDs.

    Args:
        view: The view payload from Slack
        user_id: The Slack user ID
        user_email: The user's email
        handler: The AssistantMessagingHandler instance to delegate to
    """
    private_metadata = json.loads(view.get("private_metadata", "{}"))
    message_id = private_metadata.get("message_id", "")
    channel_id = private_metadata.get("channel_id", "")
    thread_ts = private_metadata.get("thread_ts", "")

    # Extract feedback from modal
    values = view.get("state", {}).get("values", {})

    # Extract selected checkboxes (quick feedback) - using enum constants
    issues = []
    if AssistantActionIds.FEEDBACK_MODAL_QUICK_BLOCK_ID in values:
        checkboxes_data = values[AssistantActionIds.FEEDBACK_MODAL_QUICK_BLOCK_ID].get(
            AssistantActionIds.FEEDBACK_MODAL_CHECKBOXES_ACTION_ID, {}
        )
        selected_options = checkboxes_data.get("selected_options", [])
        issues = [option.get("value", "") for option in selected_options]

    # Extract additional text feedback - using enum constants
    feedback_text = ""
    if AssistantActionIds.FEEDBACK_MODAL_TEXT_BLOCK_ID in values:
        text_input_data = values[AssistantActionIds.FEEDBACK_MODAL_TEXT_BLOCK_ID].get(
            AssistantActionIds.FEEDBACK_MODAL_TEXT_INPUT_ACTION_ID, {}
        )
        feedback_text = text_input_data.get("value", "") or ""

    # Delegate to handler with extracted data
    await handler.handle_modal_submission(
        message_id=message_id,
        channel_id=channel_id,
        thread_id=thread_ts,
        user_id=user_id,
        user_email=user_email,
        issues=issues,
        feedback_text=feedback_text,
    )