CortexAssistantApiModule

Common utilities and classes for integrating with Cortex Assistant (AgentiX). Provides platform-agnostic functionality that can be used across different integrations (Slack, Teams, etc.).

python · ApiModules

Source

from typing import Optional
from enum import Enum, IntEnum
from dataclasses import dataclass
from datetime import datetime, UTC
import demistomock as demisto
from CommonServerPython import *


# ============================================================================
# Constants
# ============================================================================

THINKING_MESSAGE_ID_KEY = "thinking_message_id"

# ============================================================================
# Enums - Status, Message Types, Action IDs, and Backend Error Types
# ============================================================================


class BackendCommand(str, Enum):
    """
    Backend command names passed to ``demisto.agentixCommands()``.
    """

    SEND_TO_CONVERSATION = "sendToConversation"
    RESET_CONVERSATION = "resetConversation"
    RATE_MESSAGE = "rateMessage"


class BackendErrorCode(IntEnum):
    """
    Numeric error codes returned by the backend API.
    """

    # Configuration errors
    LLM_NOT_ENABLED = 103000

    # Permission errors
    USER_NOT_FOUND = 103102
    PERMISSION_DENIED = 103103

    # Conversation errors
    CONVERSATION_NOT_FOUND = 103201
    WRONG_USER = 103204

    # Agent errors
    AGENT_DISABLED = 103502

    @property
    def error_type(self) -> "BackendErrorType":
        """Return the corresponding BackendErrorType for this code."""
        return _CODE_TO_ERROR_TYPE[self]

    @property
    def debug_message(self) -> str:
        """Return a human-readable debug message for this error code."""
        return _CODE_TO_DEBUG_MESSAGE[self]

    @classmethod
    def from_response(cls, error_code: int | None) -> "BackendErrorCode | None":
        """Try to convert a raw error code to a BackendErrorCode, or return None."""
        if error_code is None:
            return None
        try:
            return cls(error_code)
        except ValueError:
            return None


class BackendErrorType(str, Enum):
    """
    Types of errors that can be returned from backend operations.
    Maps to error_code from backend API.
    """

    LLM_NOT_ENABLED = "llm_not_enabled"
    USER_NOT_FOUND = "user_not_found"
    PERMISSION_DENIED = "permission_denied"
    CONVERSATION_NOT_FOUND = "conversation_not_found"
    WRONG_USER = "wrong_user"
    AGENT_DISABLED = "agent_disabled"
    UNKNOWN = "unknown"

    @property
    def user_message(self) -> str:
        """Return the default user-facing message for this error type."""
        return _ERROR_TYPE_TO_USER_MESSAGE.get(self, AssistantMessages.GENERIC_ERROR)


# Mapping from BackendErrorCode → BackendErrorType
_CODE_TO_ERROR_TYPE: dict[BackendErrorCode, BackendErrorType] = {
    BackendErrorCode.LLM_NOT_ENABLED: BackendErrorType.LLM_NOT_ENABLED,
    BackendErrorCode.USER_NOT_FOUND: BackendErrorType.USER_NOT_FOUND,
    BackendErrorCode.PERMISSION_DENIED: BackendErrorType.PERMISSION_DENIED,
    BackendErrorCode.CONVERSATION_NOT_FOUND: BackendErrorType.CONVERSATION_NOT_FOUND,
    BackendErrorCode.WRONG_USER: BackendErrorType.WRONG_USER,
    BackendErrorCode.AGENT_DISABLED: BackendErrorType.AGENT_DISABLED,
}

# Mapping from BackendErrorCode → debug log message
_CODE_TO_DEBUG_MESSAGE: dict[BackendErrorCode, str] = {
    BackendErrorCode.LLM_NOT_ENABLED: "LLM not enabled in Cortex platform",
    BackendErrorCode.USER_NOT_FOUND: "User not found",
    BackendErrorCode.PERMISSION_DENIED: "Permission denied",
    BackendErrorCode.CONVERSATION_NOT_FOUND: "Conversation not found",
    BackendErrorCode.WRONG_USER: "Wrong user for conversation",
    BackendErrorCode.AGENT_DISABLED: "Agent is disabled",
}


@dataclass
class BackendResponse:
    """
    Represents a response from a backend operation.
    
    Attributes:
        success: Whether the operation succeeded
        error_type: Type of error if failed (None if successful)
        error_message: Detailed error message if failed (None if successful)
        error_code: Error code from backend if failed (None if successful)
    """

    success: bool
    error_type: BackendErrorType | None = None
    error_message: str | None = None
    error_code: int | None = None


# Timeout durations (in seconds) for each conversation status.
_STATUS_TIMEOUTS: dict[str, int] = {
    "awaiting_backend_response": 1 * 60,  # 1 minute
    "responding_with_plan": 5 * 60,  # 5 minutes
    "awaiting_agent_selection": 7 * 24 * 60 * 60,  # 7 days
    "awaiting_sensitive_action_approval": 14 * 24 * 60 * 60,  # 14 days
}


class AssistantStatus(str, Enum):
    """
    Manages the status of Assistant AI interactions.

    Status flow:
    1. AWAITING_BACKEND_RESPONSE: User message sent to backend, waiting for AI response
    2. RESPONDING_WITH_PLAN: Currently responding back with plan steps
    3. AWAITING_AGENT_SELECTION: Sent list of available agents, waiting for user to select
    4. AWAITING_SENSITIVE_ACTION_APPROVAL: Sent sensitive action message, waiting for approval/rejection
    """

    AWAITING_BACKEND_RESPONSE = "awaiting_backend_response"
    RESPONDING_WITH_PLAN = "responding_with_plan"
    AWAITING_AGENT_SELECTION = "awaiting_agent_selection"
    AWAITING_SENSITIVE_ACTION_APPROVAL = "awaiting_sensitive_action_approval"

    @classmethod
    def is_awaiting_user_action(cls, status: str) -> bool:
        """
        Check if the status indicates we're waiting for user action.

        Args:
            status: The status to check

        Returns:
            True if waiting for user action, False otherwise
        """
        return status in {cls.AWAITING_AGENT_SELECTION.value, cls.AWAITING_SENSITIVE_ACTION_APPROVAL.value}

    @classmethod
    def get_timeout_for_status(cls, status: str) -> int:
        """
        Get the timeout duration for a given status.

        Args:
            status: The status to get timeout for

        Returns:
            Timeout duration in seconds, or 0 if status is invalid
        """
        return _STATUS_TIMEOUTS.get(status, 0)

    @classmethod
    def is_expired(cls, status: str, last_updated: float) -> bool:
        """
        Check if a conversation has expired based on its status and last update time.

        Args:
            status: The conversation status
            last_updated: Unix timestamp of last update

        Returns:
            True if the conversation has expired, False otherwise
        """
        timeout = cls.get_timeout_for_status(status)
        if timeout == 0:
            return False

        current_time = datetime.now(UTC).timestamp()
        time_elapsed = current_time - last_updated

        return time_elapsed > timeout


class AssistantMessageType(str, Enum):
    """
    Message types for Assistant AI responses.

    Type mapping (from backend):
    - step: Step execution (function calls, actions)
    - model: Model/AI response (final text response)
    - error: Error message
    - user: User message
    - thought: AI thinking
    - approval: Approval request for sensitive actions
    - clarification: Clarification request
    - copilot: Copilot response
    - script: Script execution
    """

    # Message types from backend
    STEP = "step"
    MODEL = "model"
    ERROR = "error"
    USER = "user"
    THOUGHT = "thought"
    APPROVAL = "approval"
    CLARIFICATION = "clarification"
    COPILOT = "copilot"
    SCRIPT = "script"

    @classmethod
    def is_model_type(cls, message_type: str) -> bool:
        """Check if a message type is a model/final response type."""
        return message_type in {cls.MODEL.value, cls.CLARIFICATION.value, cls.COPILOT.value, cls.SCRIPT.value, cls.APPROVAL.value}

    @classmethod
    def is_step_type(cls, message_type: str) -> bool:
        """Check if a message type is a step type (step/thought)."""
        return message_type in {cls.STEP.value, cls.THOUGHT.value}

    @classmethod
    def is_approval_type(cls, message_type: str) -> bool:
        """Check if a message type requires approval."""
        return message_type == cls.APPROVAL.value

    @classmethod
    def is_error_type(cls, message_type: str) -> bool:
        """Check if a message type is an error."""
        return message_type == cls.ERROR.value


class AssistantActionIds(str, Enum):
    """
    Action IDs for Assistant interactive elements.
    """

    AGENT_SELECTION = "agent_selection"
    APPROVAL_YES = "assistant_sensitive_action_approve"
    APPROVAL_NO = "assistant_sensitive_action_reject"
    FEEDBACK = "assistant_feedback"

    # Special constants (not enum values)
    AGENT_SELECTION_VALUE_PREFIX = "assistant-agent-selection-"
    FEEDBACK_MODAL_CALLBACK_ID = "assistant_feedback_modal_callback_id"
    FEEDBACK_MODAL_QUICK_BLOCK_ID = "quick_feedback_block"
    FEEDBACK_MODAL_TEXT_BLOCK_ID = "feedback_text_block"
    FEEDBACK_MODAL_CHECKBOXES_ACTION_ID = "quick_feedback_checkboxes"
    FEEDBACK_MODAL_TEXT_INPUT_ACTION_ID = "feedback_text_input"


# ============================================================================
# Messages - User-facing text and UI labels
# ============================================================================


class AssistantMessages:
    """
    User-facing messages and UI text for Assistant AI interactions.
    These messages are platform-agnostic and can be used across different integrations.
    """

    # Bot display name (used when replacing bot mentions in messages sent to backend)
    BOT_DISPLAY_NAME = "Cortex Agentic Assistant"
    
    # Bot name format for agent responses (used in Slack username field)
    # {0} will be replaced with agent name (e.g., "Security Analyst")
    AGENT_BOT_NAME_FORMAT = "Cortex {0} Agent"

    # Commands
    RESET_SESSION_COMMAND = "!reset"
    HELP_COMMAND = "!help"

    # Default message when only bot is mentioned
    DEFAULT_BOT_MENTION_MESSAGE = "Hello"

    # Thinking indicator (shown while waiting for AI response)
    THINKING_INDICATOR = ":thought_balloon: Thinking..."

    # Context formatting
    CONTEXT_START = "--- Previous chat context ---"
    CONTEXT_END = "--- End of context ---"
    CURRENT_MESSAGE_HEADER = "**Current message**:"

    # Messages for when user action is awaited - specific to action type
    AWAITING_AGENT_SELECTION = "Select an agent from the dropdown above."
    AWAITING_APPROVAL_RESPONSE = "Approve or reject the sensitive action above."

    ONLY_LOCKED_USER_CAN_RESPOND = (
        "This thread is currently locked to {locked_user_tag}. To chat, please start a new thread."
    )

    # Messages for when backend is processing
    ALREADY_PROCESSING = "Still working on your previous request. Please wait."

    # Messages for when plan is being sent
    WAITING_FOR_COMPLETION = "Still generating a response for you."

    # Messages for action errors
    CANNOT_SELECT_AGENT = "Only {locked_user_tag} can select an agent for this thread."
    CANNOT_APPROVE_ACTION = "Only {locked_user_tag} can approve or reject this action."

    # Configuration errors
    LLM_NOT_ENABLED = (
        f"{BOT_DISPLAY_NAME} is not available. "
        "The LLM feature must be enabled in your Cortex platform by your administrator."
    )

    # Permission errors
    USER_NOT_FOUND = "You don't have an account in the system. Please contact your administrator."
    NO_ASSISTANT_PERMISSIONS = (
        f"You don't have permissions to use the {BOT_DISPLAY_NAME}. "
        "Please request the required permissions from your administrator."
    )
    THREAD_LOCKED_TO_ANOTHER_USER = (
        "This conversation is currently locked to another user. "
        "You can start a new thread or run `{bot_tag} !reset` to release the lock and start a new chat."
    )
    NOT_CONVERSATION_OWNER_FEEDBACK = "Only the chat owner can provide feedback on this message."

    # Generic error messages
    GENERIC_ERROR = "❌ An error occurred. Please try again later or contact your administrator if the issue persists."
    CONVERSATION_NOT_FOUND_ERROR = "❌ This conversation is no longer active."
    AGENT_DISABLED = "❌ The selected agent is currently disabled. Please contact your administrator to enable it."
    SYSTEM_ERROR = "❌ A system error occurred. Please try again later or contact your administrator if the issue persists."

    # Reset session messages
    RESET_SESSION_SUCCESS = "✅ Session reset successfully."
    RESET_SESSION_FAILED = "❌ Failed to reset session."
    RESET_SESSION_NO_ACTIVE_SESSION = "No active session to reset. You can start a new chat by mentioning {bot_tag}."
    RESET_SESSION_CANNOT_RESET_AWAITING_SELECTION = (
        "Can't reset because an agent hasn't been selected. Please pick an agent to get started."
    )
    RESET_SESSION_CANNOT_RESET_PROCESSING = (
        "Still working on your previous request. The response must complete before you reset the session."
    )
    RESET_SESSION_CANNOT_RESET_RESPONDING = "Cannot reset session while responding. Please wait for the response to complete."

    # Agent selection messages
    NO_AGENTS_AVAILABLE = "❌ No agents are currently available. Please try again later or contact your administrator."
    AGENT_SELECTION_FAILED = "❌ Failed to start chat with selected agent. Please try again or select a different agent."

    # Agent selection UI texts
    AGENT_SELECTION_PROMPT = "Please select an agent:"
    AGENT_SELECTION_PLACEHOLDER = "Select an agent"
    AGENT_SELECTION_CONFIRM_TITLE = "Confirm agent selection"
    AGENT_SELECTION_CONFIRM_TEXT = "Are you sure you want to use this agent?"
    AGENT_SELECTION_CONFIRM_BUTTON = "Yes, use this agent"
    AGENT_SELECTION_DENY_BUTTON = "No, let me choose again"

    # Approval UI texts
    APPROVAL_HEADER = "⚠️ Sensitive action detected. Approval required"
    APPROVAL_PROMPT = "*Should I proceed?*"
    APPROVAL_PROCEED_BUTTON = "Proceed"
    APPROVAL_CANCEL_BUTTON = "Cancel"
    APPROVAL_CONFIRM_TITLE = "Are you sure?"
    APPROVAL_CONFIRM_TEXT = "This action will be executed. Do you want to proceed?"
    APPROVAL_CONFIRM_BUTTON = "Yes, proceed"
    APPROVAL_DENY_BUTTON = "No, cancel"

    # Feedback buttons texts
    FEEDBACK_GOOD_BUTTON = "Good response"
    FEEDBACK_BAD_BUTTON = "Bad response"
    FEEDBACK_GOOD_ACCESSIBILITY = "Mark this response as good"
    FEEDBACK_BAD_ACCESSIBILITY = "Mark this response as bad"
    FEEDBACK_THANK_YOU = "Thanks for your feedback!"
    FEEDBACK_FAILED = "❌ Failed to submit feedback. Please try again."

    # Feedback modal texts
    FEEDBACK_MODAL_TITLE = "Send feedback"
    FEEDBACK_MODAL_SUBMIT = "Submit"
    FEEDBACK_MODAL_CANCEL = "Cancel"
    FEEDBACK_MODAL_QUICK_LABEL = "Quick feedback"
    FEEDBACK_MODAL_ADDITIONAL_LABEL = "Anything to add?"
    FEEDBACK_MODAL_ADDITIONAL_PLACEHOLDER = "Enter your feedback here..."

    # Feedback modal checkbox options (text is also used as value)
    FEEDBACK_OPTION_NO_ANSWER = "No answer but I expected you to know that"
    FEEDBACK_OPTION_FACTUALLY_INCORRECT = "Factually incorrect"
    FEEDBACK_OPTION_ANSWERED_ANOTHER = "Answered another question"
    FEEDBACK_OPTION_PARTIALLY_HELPFUL = "Partially helpful"
    FEEDBACK_OPTION_UNHELPFUL = "Unhelpful"

    # Help hint (sent after agent selection)
    HELP_HINT = "💡 For help, type `{bot_tag} !help`"

    # Help message (sent on !help command)
    HELP_MESSAGE = (
        "📖 *{bot_display_name} - Help*\n\n"
        "• Each thread is *locked to the user who started the chat*. "
        "Other users can start their own chat in a different thread.\n"
        "• Every message must *mention the bot* (e.g. `{bot_tag} <your question>`).\n"
        "• To *start a new chat*, open a new thread or type `{bot_tag} !reset` to release the current session.\n"
    )

    # Optional help tip for platforms that support message history retrieval
    HELP_MESSAGE_HISTORY_TIP = (
        "• To summarize {platform_name} messages (if supported by the agent), mention the source explicitly "
        "(e.g. `summarize the last 20 messages from this {platform_name} channel`).\n"
    )

    # Decision indicators
    DECISION_APPROVED = "✅ *Approved*"
    DECISION_DECLINED = "❌ *Declined*"

    # Script availability notice (plain text - platform-specific formatting is applied by subclass)
    SCRIPT_AVAILABLE_NOTICE = "A script is available for this action in the Cortex UI."


# Mapping from BackendErrorType → default user-facing message
_ERROR_TYPE_TO_USER_MESSAGE: dict[BackendErrorType, str] = {
    BackendErrorType.LLM_NOT_ENABLED: AssistantMessages.LLM_NOT_ENABLED,
    BackendErrorType.USER_NOT_FOUND: AssistantMessages.USER_NOT_FOUND,
    BackendErrorType.PERMISSION_DENIED: AssistantMessages.NO_ASSISTANT_PERMISSIONS,
    BackendErrorType.WRONG_USER: AssistantMessages.NOT_CONVERSATION_OWNER_FEEDBACK,
    BackendErrorType.CONVERSATION_NOT_FOUND: AssistantMessages.CONVERSATION_NOT_FOUND_ERROR,
    BackendErrorType.AGENT_DISABLED: AssistantMessages.AGENT_DISABLED,
    BackendErrorType.UNKNOWN: AssistantMessages.SYSTEM_ERROR,
}


# ============================================================================
# Base Handler Class
# ============================================================================


class AssistantMessagingHandler:
    """
    Base class for handling Assistant messaging across different platforms.
    This class contains the platform-agnostic logic for handling Assistant interactions.
    Platform-specific implementations (Slack, Microsoft Teams, etc.) should inherit from this class
    and implement the abstract methods.
    """

    # Integration context key for assistant conversations
    CONTEXT_KEY = "assistant_context"

    # Maximum number of previous messages to include as conversation context
    MAX_CONTEXT_MESSAGES = 5

    # Platform name - subclasses should override this
    PLATFORM_NAME = "Unknown"

    # Whether this platform supports retrieving message history (e.g. channel/thread messages).
    # Subclasses should set to True if they expose message history actions.
    SUPPORTS_MESSAGE_HISTORY = False

    def __init__(self):
        """Initialize the messaging handler"""

    # ============================================================================
    # Abstract methods - must be implemented by platform-specific subclasses
    # ============================================================================

    async def send_message_async(
        self,
        channel_id: str,
        message: str,
        thread_id: str = "",
        blocks: Optional[list] = None,
        attachments: Optional[list] = None,
        ephemeral: bool = False,
        user_id: str = "",
    ):
        """
        Send a message to the platform.
        Must be implemented by subclass.

        Args:
            channel_id: The channel/conversation ID
            message: The message text
            thread_id: Optional thread ID
            blocks: Optional platform-specific blocks
            attachments: Optional attachments
            ephemeral: Whether message should be ephemeral (visible only to a specific user)
            user_id: User ID for ephemeral messages
        """
        raise NotImplementedError("Subclass must implement send_message_async()")

    @staticmethod
    def _validate_update_message_args(text: str, blocks: list | None) -> None:
        """Validate that at least one of ``text`` or ``blocks`` is provided."""
        if not text and not blocks:
            raise ValueError("update_message requires at least one of 'text' or 'blocks'")

    async def update_message(
        self,
        channel_id: str,
        message_id: str,
        text: str = "",
        blocks: list | None = None,
    ):
        """
        Update an existing message.
        Must be implemented by subclass.
        At least one of ``text`` or ``blocks`` must be provided;
        implementations should raise ``ValueError`` otherwise.

        Args:
            channel_id: The channel/conversation ID
            message_id: The message ID
            text: New text content (at least one of text/blocks required)
            blocks: New blocks content (at least one of text/blocks required)
        """
        self._validate_update_message_args(text, blocks)
        raise NotImplementedError("Subclass must implement update_message()")

    def delete_message(
        self,
        channel_id: str,
        message_id: str,
    ) -> tuple[bool, dict]:
        """
        Delete an existing message.
        Must be implemented by subclass.

        Args:
            channel_id: The channel/conversation ID
            message_id: The message ID

        Returns:
            A tuple of (success, response) where success is a bool indicating
            whether the deletion succeeded, and response is the platform-specific
            response dict for logging purposes.
        """
        raise NotImplementedError("Subclass must implement delete_message()")

    async def get_user_info(self, user_id: str) -> dict:
        """
        Get user information.
        Must be implemented by subclass.

        Args:
            user_id: The user ID

        Returns:
            User information dictionary
        """
        raise NotImplementedError("Subclass must implement get_user_info()")

    async def get_thread_last_messages(self, channel_id: str, thread_id: str, limit: int = 20) -> list:
        """
        Get conversation history.
        Must be implemented by subclass.

        Args:
            channel_id: The channel ID
            thread_id: The thread ID
            limit: Maximum number of messages to retrieve

        Returns:
            List of messages
        """
        raise NotImplementedError("Subclass must implement get_thread_last_messages()")

    def format_user_mention(self, user_id: str) -> str:
        """
        Format a user mention for the platform.
        Must be implemented by subclass.

        Args:
            user_id: The user ID

        Returns:
            Formatted user mention string
        """
        raise NotImplementedError("Subclass must implement format_user_mention()")

    def normalize_message_from_user(self, text: str) -> str:
        """
        Normalize message text from user for backend processing.
        Must be implemented by subclass.

        Args:
            text: The message text with platform-specific formatting

        Returns:
            Normalized text suitable for backend
        """
        raise NotImplementedError("Subclass must implement normalize_message_from_user()")

    def prepare_message_blocks(self, message: str, message_type: AssistantMessageType) -> tuple:
        """
        Prepare platform-specific message blocks.
        Must be implemented by subclass.

        Args:
            message: The message text
            message_type: The message type

        Returns:
            Tuple of (blocks, attachments)
        """
        raise NotImplementedError("Subclass must implement prepare_message_blocks()")

    def prepare_merged_step_blocks(self, step_contents: list[str]) -> tuple[list, list]:
        """
        Prepare platform-specific blocks for multiple merged step messages.
        Must be implemented by subclass.

        Consecutive step-type messages are merged into a single visual message
        with dividers between them.

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

        Returns:
            Tuple of (blocks, attachments) for the merged step message
        """
        raise NotImplementedError("Subclass must implement prepare_merged_step_blocks()")

    def create_agent_selection_ui(self, agents: list) -> list:
        """
        Create agent selection UI.
        Must be implemented by subclass.

        Args:
            agents: List of available agents

        Returns:
            Platform-specific UI blocks
        """
        raise NotImplementedError("Subclass must implement create_agent_selection_ui()")

    def create_approval_ui(self) -> list:
        """
        Create approval UI for sensitive actions.
        Must be implemented by subclass.

        Returns:
            Platform-specific UI blocks
        """
        raise NotImplementedError("Subclass must implement create_approval_ui()")

    def create_script_notice_ui(self) -> dict | None:
        """
        Create a platform-specific UI block for the script availability notice.
        Must be implemented by subclass.

        Returns:
            Platform-specific block dict, or None if not supported.
        """
        raise NotImplementedError("Subclass must implement create_script_notice_ui()")

    def create_feedback_ui(self, message_id: str) -> dict:
        """
        Create feedback UI.
        Must be implemented by subclass.

        Args:
            message_id: The message ID for tracking

        Returns:
            Platform-specific feedback UI
        """
        raise NotImplementedError("Subclass must implement create_feedback_ui()")

    def post_agent_response(
        self,
        channel_id: str,
        thread_id: str,
        blocks: list,
        attachments: list,
        agent_name: str = "",
        fallback_text: str = "",
    ) -> Optional[dict]:
        """
        Send a new agent message to the platform.
        Must be implemented by subclass.

        Args:
            channel_id: The channel ID
            thread_id: The thread ID
            blocks: Message blocks
            attachments: Message attachments
            agent_name: Optional agent name to display (e.g., "Security Analyst")
            fallback_text: Plain text to use if blocks/attachments fail

        Returns:
            Response dict with 'ts' (message timestamp) if successful, None otherwise
        """
        raise NotImplementedError("Subclass must implement post_agent_response_sync()")


    def update_context(self, context_updates: dict):
        """
        Update the integration context.
        Must be implemented by subclass.

        Args:
            context_updates: Dictionary of updates to apply
        """
        raise NotImplementedError("Subclass must implement update_context()")

    async def show_feedback_modal(
        self,
        trigger_id: str,
        message_id: str,
        channel_id: str,
        thread_id: str,
    ):
        """
        Open a feedback modal for negative feedback collection.
        Must be implemented by subclass.

        Args:
            trigger_id: The trigger ID for opening the modal
            message_id: The message ID for tracking
            channel_id: The channel ID
            thread_id: The thread ID
        """
        raise NotImplementedError("Subclass must implement open_feedback_modal()")

    def handle_backend_response(self, response: Any, operation: str) -> BackendResponse:
        """
        Handles backend response and returns structured result.
        Uses error_code from backend to determine error type.
        
        Args:
            response: The response from backend
            operation: The operation name (for logging)
            
        Returns:
            BackendResponse with success status and error details
        """
        if isinstance(response, dict):
            if response.get("success") or response.get("agents"):
                demisto.debug(f"Backend {operation} succeeded")
                return BackendResponse(success=True)
            
            raw_error_code = response.get("error_code")
            error_msg = str(response.get("error", ""))

            known_code = BackendErrorCode.from_response(raw_error_code)
            if known_code is not None:
                demisto.debug(f"{known_code.debug_message} for {operation}: {error_msg}")
                return BackendResponse(
                    success=False, error_type=known_code.error_type, error_message=error_msg, error_code=raw_error_code
                )

            demisto.error(f"Backend {operation} failed with error_code={raw_error_code}: {error_msg}")
            return BackendResponse(
                success=False, error_type=BackendErrorType.UNKNOWN, error_message=error_msg, error_code=raw_error_code
            )
        else:
            error_msg = f"Unexpected response type: {type(response)}"
            demisto.error(f"Backend {operation} returned unexpected response: {response}")
            return BackendResponse(success=False, error_type=BackendErrorType.UNKNOWN, error_message=error_msg)

    async def submit_feedback(
        self,
        message_id: str,
        is_positive: bool,
        thread_id: str,
        channel_id: str,
        username: str,
        issues: Optional[list] = None,
        message: str = "",
    ) -> BackendResponse:
        """
        Submit feedback to backend.
        Platform-agnostic implementation.

        Args:
            message_id: The message ID
            is_positive: True for positive feedback, False for negative
            thread_id: The thread ID
            channel_id: The channel ID
            username: The username
            issues: Optional list of issues (for negative feedback)
            message: Optional feedback message

        Returns:
            BackendResponse indicating success or failure
        """
        args = {
            "message_id": message_id,
            "is_liked": is_positive,
            "thread_id": thread_id,
            "channel_id": channel_id,
            "username": username,
        }
        if issues:
            args["issues"] = issues
        if message:
            args["improvement_suggestion"] = message

        raw_response = demisto.agentixCommands(BackendCommand.RATE_MESSAGE, args)
        return self.handle_backend_response(raw_response, BackendCommand.RATE_MESSAGE)

    # ============================================================================
    # Platform-agnostic methods - shared logic across all platforms
    # ============================================================================

    def delete_expired_conversations(self, assistant: dict) -> dict:
        """
        Cleans up expired conversations from the assistant context.
        Each status has a different timeout duration.

        Args:
            assistant: The assistant context dictionary

        Returns:
            Updated assistant dictionary with expired conversations removed
        """
        if not assistant:
            return {}

        expired_keys = []

        for assistant_id_key, conversation in assistant.items():
            status = conversation.get("status", "")
            last_updated = conversation.get("last_updated", 0)

            # Check if conversation has expired
            if AssistantStatus.is_expired(status, last_updated):
                expired_keys.append(assistant_id_key)
                demisto.debug(
                    f"Conversation {assistant_id_key} expired (status: {status}, "
                    f"last_updated: {last_updated}, timeout: {AssistantStatus.get_timeout_for_status(status)}s)"
                )

        # Remove expired conversations
        for key in expired_keys:
            del assistant[key]
            demisto.info(f"Cleaned up expired conversation: {key}")

        if expired_keys:
            demisto.info(f"Cleaned up {len(expired_keys)} expired conversations")

        return assistant

    def delete_assistant_conversations_from_context(self):
        """
        Checks and cleans up expired Assistant conversations from integration context.
        This should be called periodically (e.g., in long_running_loop).
        Handles loading from context, cleanup, and saving back to context.
        """
        try:
            # Get integration context
            integration_context = get_integration_context(sync=True)
            assistant = integration_context.get(self.CONTEXT_KEY, {})

            if not assistant:
                return

            # Parse if it's a string
            if isinstance(assistant, str):
                try:
                    assistant = json.loads(assistant)
                except json.JSONDecodeError:
                    demisto.error(f"Failed to parse assistant context as JSON: {assistant[:200]}")
                    assistant = {}

            # Store original count before cleanup
            original_count = len(assistant)

            # Cleanup expired conversations
            deleted_converstations = self.delete_expired_conversations(assistant)

            # Update context if anything was cleaned
            if len(deleted_converstations) < original_count:
                demisto.debug(f"Updating context after cleanup: {original_count} -> {len(deleted_converstations)} conversations")
                set_to_integration_context_with_retries({self.CONTEXT_KEY: deleted_converstations}, sync=True)
        except Exception as e:
            demisto.error(f"Failed to cleanup expired Assistant conversations: {e}")

    async def handle_reset_session(
        self,
        text: str,
        user_id: str,
        channel_id: str,
        thread_id: str,
        assistant: dict,
        assistant_id_key: str,
        bot_id: str,
        user_email: str,
    ) -> tuple[bool, dict]:
        """
        Handles reset session command.
        Checks if the message is exactly "@BotName !reset" (case-insensitive).

        Args:
            text: The message text
            user_id: The user ID
            channel_id: The channel ID
            thread_id: The thread ID
            assistant: The assistant context dictionary
            assistant_id_key: The unique key for this conversation
            bot_id: The bot user ID
            user_email: The user email address

        Returns:
            Tuple of (is_reset_command, updated_assistant)
        """
        # Check for exact "!reset" command
        # Format: @BotName !reset (with optional whitespace)
        bot_mention = self.format_user_mention(bot_id)
        # Remove the bot mention and check if remaining text is exactly "!reset"
        text_without_mention = text.replace(bot_mention, "").strip()

        if text_without_mention.lower() != AssistantMessages.RESET_SESSION_COMMAND:
            return False, assistant

        # Check status to determine if reset is allowed
        if assistant_id_key in assistant:
            status = assistant[assistant_id_key].get("status", "")

            # For agent selection - release lock locally without calling backend
            if status == AssistantStatus.AWAITING_AGENT_SELECTION.value:
                del assistant[assistant_id_key]
                await self.send_message_async(
                    channel_id,
                    AssistantMessages.RESET_SESSION_SUCCESS,
                    thread_id=thread_id,
                    user_id=user_id,
                )
                return True, assistant

            # Cannot reset while processing
            if status == AssistantStatus.AWAITING_BACKEND_RESPONSE.value:
                await self.send_message_async(
                    channel_id,
                    AssistantMessages.RESET_SESSION_CANNOT_RESET_PROCESSING,
                    thread_id=thread_id,
                    ephemeral=True,
                    user_id=user_id,
                )
                return True, assistant

            # Cannot reset while responding
            if status == AssistantStatus.RESPONDING_WITH_PLAN.value:
                await self.send_message_async(
                    channel_id,
                    AssistantMessages.RESET_SESSION_CANNOT_RESET_RESPONDING,
                    thread_id=thread_id,
                    ephemeral=True,
                    user_id=user_id,
                )
                return True, assistant

        # For AWAITING_SENSITIVE_ACTION_APPROVAL or no lock - allow reset
        # Call backend to reset conversation
        demisto.debug(f"Resetting conversation for user {user_email} in channel {channel_id}")
        raw_response = demisto.agentixCommands(
            BackendCommand.RESET_CONVERSATION,
            {
                "channel_id": channel_id,
                "thread_id": thread_id,
                "username": user_email,
            },
        )

        backend_response = self.handle_backend_response(raw_response, BackendCommand.RESET_CONVERSATION)

        if backend_response.success:
            # Remove from assistant context
            if assistant_id_key in assistant:
                del assistant[assistant_id_key]

            await self.send_message_async(
                channel_id, AssistantMessages.RESET_SESSION_SUCCESS, thread_id=thread_id, user_id=user_id
            )
        elif backend_response.error_type == BackendErrorType.CONVERSATION_NOT_FOUND:
            # Backend says no active session (conversation not found)
            no_session_msg = AssistantMessages.RESET_SESSION_NO_ACTIVE_SESSION.format(
                bot_tag=self.format_user_mention(bot_id)
            )
            await self.send_message_async(
                channel_id, no_session_msg, thread_id=thread_id, ephemeral=True, user_id=user_id
            )
        else:
            error_msg = backend_response.error_type.user_message if backend_response.error_type else AssistantMessages.RESET_SESSION_FAILED
            await self.send_message_async(
                channel_id, error_msg, thread_id=thread_id, ephemeral=True, user_id=user_id
            )

        return True, assistant

    async def handle_modal_submission(
        self,
        message_id: str,
        channel_id: str,
        thread_id: str,
        user_id: str,
        user_email: str,
        issues: list,
        feedback_text: str,
    ):
        """
        Handles modal submissions (e.g., negative feedback).
        Platform-agnostic logic that submits feedback.

        Args:
            message_id: The message ID
            channel_id: The channel ID
            thread_id: The thread ID
            user_id: The user ID
            user_email: The user's email
            issues: List of selected issues
            feedback_text: Additional feedback text
        """
        # Send negative feedback with checkboxes and text to backend
        backend_response = await self.submit_feedback(
            message_id=message_id,
            is_positive=False,
            thread_id=thread_id,
            channel_id=channel_id,
            username=user_email,
            issues=issues,
            message=feedback_text,
        )

        # Send appropriate message based on backend response
        if backend_response.success:
            feedback_msg = AssistantMessages.FEEDBACK_THANK_YOU
        else:
            feedback_msg = backend_response.error_type.user_message if backend_response.error_type else AssistantMessages.FEEDBACK_FAILED

        await self.send_message_async(
            channel_id, feedback_msg, thread_id=thread_id, ephemeral=True, user_id=user_id
        )

    async def _handle_action_feedback(
        self,
        action_value: str,
        channel_id: str,
        thread_id: str,
        user_id: str,
        user_email: str,
        trigger_id: str,
    ) -> None:
        """
        Handles feedback button actions (positive/negative).

        Args:
            action_value: The action value string (e.g., "positive-message_id")
            channel_id: The channel ID
            thread_id: The thread ID
            user_id: The user ID
            user_email: The user's email
            trigger_id: The trigger ID for modals
        """
        # Value format: "positive-message_id" or "negative-message_id"
        # message_id can contain hyphens (e.g., UUID), so split only on first hyphen
        parts = action_value.split("-", 1)
        if len(parts) != 2:
            demisto.error(f"Invalid feedback value format: {action_value}")
            return

        feedback_type, message_id = parts
        is_positive = feedback_type == "positive"

        if is_positive:
            # Positive feedback - send immediately
            backend_response = await self.submit_feedback(
                message_id=message_id,
                is_positive=True,
                thread_id=thread_id,
                channel_id=channel_id,
                username=user_email,
            )

            # Send appropriate message based on backend response
            if backend_response.success:
                feedback_msg = AssistantMessages.FEEDBACK_THANK_YOU
            else:
                feedback_msg = backend_response.error_type.user_message if backend_response.error_type else AssistantMessages.FEEDBACK_FAILED

            await self.send_message_async(
                channel_id, feedback_msg, thread_id=thread_id, ephemeral=True, user_id=user_id
            )
        else:
            # Negative feedback - open modal
            if trigger_id:
                try:
                    await self.show_feedback_modal(trigger_id, message_id, channel_id, thread_id)
                except Exception as e:
                    demisto.error(f"Failed to open feedback modal: {e}")
                    # Fallback to ephemeral message
                    await self.send_message_async(
                        channel_id, AssistantMessages.FEEDBACK_THANK_YOU, thread_id=thread_id, ephemeral=True, user_id=user_id
                    )

    async def handle_action(
        self,
        actions: list,
        user_id: str,
        user_email: str,
        channel_id: str,
        thread_id: str,
        message: dict,
        assistant: dict,
        assistant_id_key: str,
        trigger_id: str,
        bot_id: str = "",
    ) -> dict:
        """
        Handles interactive actions (agent selection, approval, feedback).
        Platform-agnostic logic that uses platform-specific methods.

        Args:
            actions: The list of actions from the payload
            user_id: The user ID
            user_email: The user's email
            channel_id: The channel ID
            thread_id: The thread ID
            message: The message dict from payload
            assistant: The assistant context dictionary
            assistant_id_key: The unique key for this conversation
            trigger_id: The trigger ID for modals
            bot_id: The bot user ID (used for help hint after agent selection)

        Returns:
            Updated assistant dictionary
        """
        message_id = message.get("ts", "")

        if not actions:
            demisto.error("Received action event with empty actions list")
            return assistant

        # Decode the action payload
        action = actions[0]
        action_id = action.get("action_id", "")
        action_value = action.get("value", "")

        demisto.debug(f"Handling action: {action_id} with value: {action_value}")

        # OPTION 1: Feedback Buttons
        if action_id == AssistantActionIds.FEEDBACK.value:
            await self._handle_action_feedback(
                action_value=action_value,
                channel_id=channel_id,
                thread_id=thread_id,
                user_id=user_id,
                user_email=user_email,
                trigger_id=trigger_id,
            )
            # Feedback doesn't require active conversation
            return assistant

        # For other actions, check if conversation exists
        if assistant_id_key not in assistant:
            demisto.debug(f"Conversation {assistant_id_key} not found.")
            return assistant

        locked_user = assistant[assistant_id_key].get("user", "")

        # OPTION 2: Agent Selection
        if action_id == AssistantActionIds.AGENT_SELECTION.value:
            await self._handle_action_agent_selection(
                action=action,
                user_id=user_id,
                user_email=user_email,
                channel_id=channel_id,
                thread_id=thread_id,
                message_id=message_id,
                assistant=assistant,
                assistant_id_key=assistant_id_key,
                locked_user=locked_user,
                bot_id=bot_id,
            )

        # OPTION 3: Sensitive Action Approval
        elif action_id in [AssistantActionIds.APPROVAL_YES.value, AssistantActionIds.APPROVAL_NO.value]:
            await self._handle_action_sensitive_action_approval(
                action_id=action_id,
                user_id=user_id,
                user_email=user_email,
                channel_id=channel_id,
                thread_id=thread_id,
                message=message,
                message_id=message_id,
                assistant=assistant,
                assistant_id_key=assistant_id_key,
                locked_user=locked_user,
            )

        return assistant

    async def _handle_action_agent_selection(
        self,
        action: dict,
        user_id: str,
        user_email: str,
        channel_id: str,
        thread_id: str,
        message_id: str,
        assistant: dict,
        assistant_id_key: str,
        locked_user: str,
        bot_id: str,
    ) -> None:
        """
        Handles agent selection actions from the dropdown UI.

        Args:
            action: The action dict from the payload
            user_id: The user ID
            user_email: The user's email
            channel_id: The channel ID
            thread_id: The thread ID
            message_id: The message ID
            assistant: The assistant context dictionary (mutated in place)
            assistant_id_key: The unique key for this conversation
            locked_user: The user ID that owns the conversation
            bot_id: The bot user ID (used for help hint)
        """
        selected_option = action.get("selected_option", {})
        option_value = selected_option.get("value", "")
        original_message = assistant[assistant_id_key].get("message", "")

        if user_id == locked_user:
            # Correct user selected an agent
            selected_agent_id = option_value.replace(AssistantActionIds.AGENT_SELECTION_VALUE_PREFIX.value, "")
            selected_agent_name = selected_option.get("text", {}).get("text", "")

            # Send message to backend with selected agent
            raw_response = demisto.agentixCommands(
                BackendCommand.SEND_TO_CONVERSATION,
                {
                    "channel_id": channel_id,
                    "thread_id": thread_id,
                    "message": original_message,
                    "username": user_email,
                    "agent_id": selected_agent_id,
                },
            )

            backend_response = self.handle_backend_response(raw_response, "sendToConversation (agent selection)")

            if backend_response.success:
                # Update the original message to show selection
                await self.update_message(channel_id, message_id, text=f"Selected agent: {selected_agent_name}", blocks=[])

                # Send help hint as ephemeral message to the user
                if bot_id:
                    help_hint = AssistantMessages.HELP_HINT.format(bot_tag=self.format_user_mention(bot_id))
                    await self.send_message_async(
                        channel_id, help_hint, thread_id=thread_id, user_id=user_id
                    )

                # Send thinking indicator
                thinking_response = await self.send_message_async(
                    channel_id, AssistantMessages.THINKING_INDICATOR, thread_id=thread_id
                )
                thinking_ts = thinking_response.get("ts") if thinking_response else None

                # Update status
                assistant[assistant_id_key]["status"] = AssistantStatus.AWAITING_BACKEND_RESPONSE.value
                assistant[assistant_id_key]["selected_agent"] = selected_agent_id
                assistant[assistant_id_key]["last_updated"] = datetime.now(UTC).timestamp()

                # Store thinking message ID if sent successfully
                if thinking_ts:
                    assistant[assistant_id_key][THINKING_MESSAGE_ID_KEY] = thinking_ts
            else:
                # Backend call failed - show appropriate error message
                if backend_response.error_type == BackendErrorType.WRONG_USER:
                    error_msg = AssistantMessages.THREAD_LOCKED_TO_ANOTHER_USER.format(bot_tag="the assistant")
                elif backend_response.error_type:
                    error_msg = backend_response.error_type.user_message
                else:
                    error_msg = AssistantMessages.AGENT_SELECTION_FAILED
                    if backend_response.error_code:
                        error_msg = f"{error_msg} (Error code: {backend_response.error_code})"
                
                await self.send_message_async(
                    channel_id, error_msg, thread_id=thread_id, ephemeral=True, user_id=user_id
                )
                # Keep the conversation in AWAITING_AGENT_SELECTION status so user can try again
        else:
            # Wrong user trying to select
            error_msg = AssistantMessages.CANNOT_SELECT_AGENT.format(locked_user_tag=self.format_user_mention(locked_user))
            await self.send_message_async(channel_id, error_msg, thread_id=thread_id, ephemeral=True, user_id=user_id)

    async def _handle_action_sensitive_action_approval(
        self,
        action_id: str,
        user_id: str,
        user_email: str,
        channel_id: str,
        thread_id: str,
        message: dict,
        message_id: str,
        assistant: dict,
        assistant_id_key: str,
        locked_user: str,
    ) -> None:
        """
        Handles sensitive action approval/rejection actions.

        Args:
            action_id: The action ID (approve or reject)
            user_id: The user ID
            user_email: The user's email
            channel_id: The channel ID
            thread_id: The thread ID
            message: The original message dict from payload
            message_id: The message ID
            assistant: The assistant context dictionary (mutated in place)
            assistant_id_key: The unique key for this conversation
            locked_user: The user ID that owns the conversation
        """
        if user_id == locked_user:
            # Correct user responded
            is_approved = action_id == AssistantActionIds.APPROVAL_YES.value

            # Send response to backend
            raw_response = demisto.agentixCommands(
                BackendCommand.SEND_TO_CONVERSATION,
                {
                    "channel_id": channel_id,
                    "thread_id": thread_id,
                    "message": "Yes" if is_approved else "No",
                    "username": user_email,
                },
            )
            backend_response = self.handle_backend_response(raw_response, "sendToConversation (approval)")

            if backend_response.success:
                # Update the original message: replace the actions block with a decision indicator,
                # keeping it above the feedback buttons (which are the last block).
                decision_indicator = AssistantMessages.DECISION_APPROVED if is_approved else AssistantMessages.DECISION_DECLINED
                original_blocks = message.get("blocks", [])
                updated_blocks = [block for block in original_blocks if block.get("type") != "actions"]
                decision_block = {"type": "context", "elements": [{"type": "mrkdwn", "text": decision_indicator}]}
                # Insert before the last block (feedback buttons) to maintain visual order
                feedback_index = len(updated_blocks) - 1 if updated_blocks else 0
                updated_blocks.insert(feedback_index, decision_block)

                try:
                    await self.update_message(channel_id, message_id, blocks=updated_blocks)
                except Exception as e:
                    demisto.error(f"Failed to update approval message: {e}")
                    # Fallback
                    await self.update_message(channel_id, message_id, text=decision_indicator, blocks=[])

                # Send thinking indicator
                thinking_response = await self.send_message_async(
                    channel_id, AssistantMessages.THINKING_INDICATOR, thread_id=thread_id
                )
                thinking_ts = thinking_response.get("ts") if thinking_response else None

                # Update status
                assistant[assistant_id_key]["status"] = AssistantStatus.AWAITING_BACKEND_RESPONSE.value
                assistant[assistant_id_key]["sensitive_action_response"] = "approved" if is_approved else "rejected"
                assistant[assistant_id_key]["last_updated"] = datetime.now(UTC).timestamp()

                # Store thinking message ID if sent successfully
                if thinking_ts:
                    assistant[assistant_id_key][THINKING_MESSAGE_ID_KEY] = thinking_ts
            else:
                # Backend call failed - show appropriate error
                if backend_response.error_type == BackendErrorType.WRONG_USER:
                    error_msg = AssistantMessages.THREAD_LOCKED_TO_ANOTHER_USER.format(bot_tag="the assistant")
                elif backend_response.error_type:
                    error_msg = backend_response.error_type.user_message
                else:
                    error_msg = "Failed to process your response. Please try again."
                    if backend_response.error_code:
                        error_msg = f"{error_msg} (Error code: {backend_response.error_code})"
                await self.send_message_async(channel_id, error_msg, thread_id=thread_id, ephemeral=True, user_id=user_id)
        else:
            # Wrong user trying to respond
            error_msg = AssistantMessages.CANNOT_APPROVE_ACTION.format(locked_user_tag=self.format_user_mention(locked_user))
            await self.send_message_async(channel_id, error_msg, thread_id=thread_id, ephemeral=True, user_id=user_id)

    def format_context_messages(self, context_messages: list[dict]) -> str:
        """
        Formats a list of context messages into a string.
        Platform-agnostic formatting logic.

        Args:
            context_messages: List of message dicts with 'user' and 'text' keys

        Returns:
            Formatted context string
        """
        if not context_messages:
            return ""

        # Create formatted context string
        context_lines = [AssistantMessages.CONTEXT_START]
        for ctx_msg in reversed(context_messages):  # Show oldest first
            context_lines.append(f"**{ctx_msg['user']}**: {ctx_msg['text']}")
        context_lines.append(AssistantMessages.CONTEXT_END)
        context_lines.append("")  # Empty line before current message

        return "\n".join(context_lines)

    def format_source_chat_context(self, channel_id: str, thread_id: str) -> str:
        """
        Formats source chat metadata into a context string.
        Includes the platform name, channel ID, and thread ID so the backend
        knows where this conversation originated.

        Args:
            channel_id: The channel ID
            thread_id: The thread ID

        Returns:
            Formatted source chat context string
        """
        return (
            "--- Source chat context ---\n"
            "The following chat session metadata is automatically attached.\n"
            f"This chat was initiated from {self.PLATFORM_NAME}.\n"
            f"channel_id: {channel_id}\n"
            f"thread_id: {thread_id}\n"
            "--- End of source chat context ---\n"
        )

    async def get_conversation_context_formatted(
        self,
        channel_id: str,
        thread_id: str,
        bot_id: str,
        current_message_id: str,
        max_context_messages: int = MAX_CONTEXT_MESSAGES,
    ) -> str:
        """
        Retrieves and formats conversation context.
        Must be implemented by subclass to handle platform-specific message parsing.

        Args:
            channel_id: The channel ID
            thread_id: The thread ID
            bot_id: The bot user ID
            current_message_id: The current message ID
            max_context_messages: Maximum number of previous messages to include as context

        Returns:
            Formatted context string
        """
        raise NotImplementedError("Subclass must implement get_conversation_context_formatted()")

    async def handle_bot_mention(
        self,
        text: str,
        user_id: str,
        user_email: str,
        channel_id: str,
        thread_id: str,
        assistant: dict,
        assistant_id_key: str,
        bot_id: str,
        message_id: str,
    ) -> dict:
        """
        Handles when the bot is mentioned in a message for Assistant AI.
        This is platform-agnostic logic.

        Args:
            text: The message text
            user_id: The user ID
            user_email: The user's email
            channel_id: The channel ID
            thread_id: The thread ID
            assistant: The assistant context dictionary
            assistant_id_key: The unique key for this conversation
            bot_id: The bot user ID
            message_id: The current message ID

        Returns:
            Updated assistant dictionary - to be saved by caller
        """

        # Check for "!help" command first
        bot_mention = self.format_user_mention(bot_id)
        text_without_mention = text.replace(bot_mention, "").strip()

        if text_without_mention.lower() == AssistantMessages.HELP_COMMAND:
            help_msg = AssistantMessages.HELP_MESSAGE.format(
                bot_display_name=AssistantMessages.BOT_DISPLAY_NAME,
                bot_tag=bot_mention,
            )
            if self.SUPPORTS_MESSAGE_HISTORY:
                help_msg += AssistantMessages.HELP_MESSAGE_HISTORY_TIP.format(platform_name=self.PLATFORM_NAME)
            await self.send_message_async(channel_id, help_msg, thread_id=thread_id, user_id=user_id)
            return assistant

        # Check for "!reset" command
        is_reset, assistant = await self.handle_reset_session(text, user_id, channel_id, thread_id, assistant, assistant_id_key, bot_id, user_email)
        if is_reset:
            return assistant

        # Check if there's already an active conversation
        if assistant_id_key in assistant:
            status = assistant[assistant_id_key].get("status", "")
            locked_user = assistant[assistant_id_key].get("user", "")

            # Determine the appropriate message
            message_to_send = None

            if AssistantStatus.is_awaiting_user_action(status):
                # Waiting for user action (agent selection or approval)
                if locked_user == user_id:
                    # Show specific message based on what we're waiting for
                    if status == AssistantStatus.AWAITING_AGENT_SELECTION.value:
                        message_to_send = AssistantMessages.AWAITING_AGENT_SELECTION
                    elif status == AssistantStatus.AWAITING_SENSITIVE_ACTION_APPROVAL.value:
                        message_to_send = AssistantMessages.AWAITING_APPROVAL_RESPONSE
                else:
                    message_to_send = AssistantMessages.ONLY_LOCKED_USER_CAN_RESPOND.format(
                        locked_user_tag=self.format_user_mention(locked_user)
                    )

            elif status == AssistantStatus.AWAITING_BACKEND_RESPONSE.value:
                # Already processing a previous message
                message_to_send = AssistantMessages.ALREADY_PROCESSING

            elif status == AssistantStatus.RESPONDING_WITH_PLAN.value:
                # Currently responding with a plan
                if locked_user == user_id:
                    message_to_send = AssistantMessages.WAITING_FOR_COMPLETION
                else:
                    message_to_send = AssistantMessages.ONLY_LOCKED_USER_CAN_RESPOND.format(
                        locked_user_tag=self.format_user_mention(locked_user)
                    )

            # Send message if needed
            if message_to_send:
                await self.send_message_async(channel_id, message_to_send, thread_id=thread_id, ephemeral=True, user_id=user_id)
            return assistant

        # Get conversation context
        context = await self.get_conversation_context_formatted(channel_id, thread_id, bot_id, message_id)

        # Replace bot mention with friendly display name for backend
        bot_mention = self.format_user_mention(bot_id)
        text_cleaned = text.replace(bot_mention, "") or AssistantMessages.DEFAULT_BOT_MENTION_MESSAGE

        # Normalize message for backend (decode HTML entities, preserve structure)
        text_normalized = self.normalize_message_from_user(text_cleaned)

        # Normalize context as well
        context_normalized = self.normalize_message_from_user(context) if context else ""

        # Prepare message with context
        message_with_context = text_normalized
        if context_normalized:
            message_with_context = f"{context_normalized}\n{AssistantMessages.CURRENT_MESSAGE_HEADER}\n{text_normalized}"

        # Send message to backend using agentixCommands
        demisto.debug(f"Sending user message to backend: channel={channel_id}, thread={thread_id}, user={user_email}")
        raw_response = demisto.agentixCommands(
            BackendCommand.SEND_TO_CONVERSATION,
            {
                "channel_id": channel_id,
                "thread_id": thread_id,
                "message": message_with_context,
                "username": user_email,
            },
        )

        backend_response = self.handle_backend_response(raw_response, "sendToConversation (bot mention)")

        # Check if response contains agent list (requires user to select an agent)
        if "agents" in raw_response:
            agents_list = raw_response.get("agents", [])
            demisto.debug(f"Backend returned {len(agents_list) if isinstance(agents_list, list) else 0} agents for selection")
            # Check if agents list is empty or UI creation failed
            if agents_list:
                # Backend returned a list of agents - user needs to select one
                # Create agent selection UI
                agent_selection_blocks = self.create_agent_selection_ui(agents_list)

                if agent_selection_blocks:
                    # Send agent selection UI
                    await self.send_message_async(channel_id, "", thread_id, blocks=agent_selection_blocks)

                    # Prepend source chat metadata so the backend knows where this conversation originated
                    source_context = self.format_source_chat_context(channel_id, thread_id)
                    message_with_metadata = f"{source_context}\n{message_with_context}"

                    # Lock the conversation with agent selection status
                    assistant[assistant_id_key] = {
                        "date": thread_id,
                        "user": user_id,
                        "message": message_with_metadata,
                        "channel_id": channel_id,
                        "thread_id": thread_id,
                        "status": AssistantStatus.AWAITING_AGENT_SELECTION.value,
                        "last_updated": datetime.now(UTC).timestamp(),
                    }
                    demisto.debug(f"Locked conversation {assistant_id_key} for agent selection")
                else:
                    # Failed to create agent selection UI
                    demisto.error("Failed to create agent selection UI despite having agents")
                    await self.send_message_async(
                        channel_id, AssistantMessages.NO_AGENTS_AVAILABLE, thread_id=thread_id, ephemeral=True, user_id=user_id
                    )
            else:
                # Empty agents list
                demisto.error("Received empty agents list from backend")
                await self.send_message_async(
                    channel_id, AssistantMessages.NO_AGENTS_AVAILABLE, thread_id=thread_id, ephemeral=True, user_id=user_id
                )

        elif backend_response.success:
            # Send thinking indicator
            thinking_response = await self.send_message_async(channel_id, AssistantMessages.THINKING_INDICATOR, thread_id=thread_id)
            thinking_ts = thinking_response.get("ts") if thinking_response else None

            # Lock the conversation with initial status
            assistant[assistant_id_key] = {
                "date": thread_id,
                "user": user_id,
                "message": text,
                "channel_id": channel_id,
                "thread_id": thread_id,
                "status": AssistantStatus.AWAITING_BACKEND_RESPONSE.value,
                "last_updated": datetime.now(UTC).timestamp(),
            }

            # Store thinking message ID if sent successfully
            if thinking_ts:
                assistant[assistant_id_key][THINKING_MESSAGE_ID_KEY] = thinking_ts
            
            demisto.debug(f"Locked conversation {assistant_id_key}, awaiting backend response")

        else:
            # Handle errors - determine message and whether it should be ephemeral
            error_msg = None
            is_ephemeral = False
            
            if backend_response.error_type == BackendErrorType.USER_NOT_FOUND:
                # Public message with user tag
                user_mention = self.format_user_mention(user_id)
                error_msg = f"{user_mention} {backend_response.error_type.user_message}"
            elif backend_response.error_type == BackendErrorType.PERMISSION_DENIED:
                # Public message with user tag
                user_mention = self.format_user_mention(user_id)
                error_msg = f"{user_mention} {backend_response.error_type.user_message}"
            elif backend_response.error_type == BackendErrorType.WRONG_USER:
                is_ephemeral = True
                error_msg = AssistantMessages.THREAD_LOCKED_TO_ANOTHER_USER.format(bot_tag=self.format_user_mention(bot_id))
            elif backend_response.error_type:
                error_msg = backend_response.error_type.user_message
                if backend_response.error_code:
                    error_msg = f"{error_msg} (Error code: {backend_response.error_code})"
            else:
                demisto.error(f"Backend sendToConversation failed: {raw_response}")
                error_msg = AssistantMessages.SYSTEM_ERROR
                if backend_response.error_code:
                    error_msg = f"{error_msg} (Error code: {backend_response.error_code})"
            
            # Send error message
            await self.send_message_async(
                channel_id, error_msg, thread_id=thread_id, ephemeral=is_ephemeral, user_id=user_id
            )

        return assistant

    @staticmethod
    def _unescape_content(text: str) -> str:
        """Replace common escaped characters with their actual characters."""
        return text.replace("\\n", "\n").replace('\\"', '"').replace("\\'", "'")

    @staticmethod
    def _group_messages_by_type(messages: list[dict]) -> list[list[dict]]:
        """
        Groups consecutive messages by their response_type category.
        Step-type messages (step, thought) are grouped together and will be
        merged into a single visual message with dividers. All other types
        are kept as individual groups (one message per group).

        Args:
            messages: List of message dicts with 'content', 'response_type',
                      'is_final', 'message_id', and 'metadata' keys.

        Returns:
            List of groups, where each group is a list of message dicts.
        """
        groups: list[list[dict]] = []
        current_group: list[dict] = []

        for msg in messages:
            response_type = msg.get("response_type", "")
            is_step = AssistantMessageType.is_step_type(response_type)

            if not current_group:
                current_group.append(msg)
            elif is_step and AssistantMessageType.is_step_type(current_group[0].get("response_type", "")):
                # Both are step types — keep grouping
                current_group.append(msg)
            else:
                # Different category — flush current group and start new one
                groups.append(current_group)
                current_group = [msg]

        if current_group:
            groups.append(current_group)

        return groups

    def send_agent_response(
        self,
        channel_id: str,
        thread_id: str,
        messages: list[dict],
        assistant_context: dict | None = None,
        assistant_id_key: str = "",
        agent_name: str = "",
        user_id: str = "",
    ) -> dict:
        """
        Sends agent response(s) and updates the Assistant status accordingly.
        This is platform-agnostic logic that uses platform-specific methods.

        Messages are grouped by response_type category:
        - Consecutive step-type messages (step/thought) are merged into a single
          visual message with platform-specific dividers between them.
        - All other types are sent as individual messages.

        Completion is determined by the last message's is_final field.

        Args:
            channel_id: The channel ID
            thread_id: The thread ID
            messages: List of message dicts, each containing:
                - content (str): The message text
                - response_type (str): The message type (from AssistantMessageType)
                - is_final (bool): Whether this is the final message
                - message_id (str): Optional message ID for feedback tracking
                - metadata (dict): Optional metadata
            assistant_context: The assistant context dictionary
            assistant_id_key: The unique key for this conversation
            agent_name: Optional agent name to display in message
            user_id: Optional user ID to mention in model responses

        Returns:
            Updated assistant dictionary

        Raises:
            ValueError: If any message's response_type is not valid
        """
        if not assistant_context:
            assistant_context = {}

        if not messages:
            demisto.debug("No messages to send")
            demisto.results("Agent response sent successfully.")
            return assistant_context

        # Derive completed from the last message's is_final field
        completed = messages[-1].get("is_final", False)

        # Validate all message types
        for msg in messages:
            response_type = msg.get("response_type", "")
            try:
                AssistantMessageType(response_type)
            except ValueError:
                error_msg = (
                    f"Invalid response_type: '{response_type}'. "
                    f"Must be one of: {', '.join([t.value for t in AssistantMessageType])}"
                )
                demisto.error(error_msg)
                raise ValueError(error_msg)

        demisto.debug(
            f"Sending agent response: {len(messages)} messages, completed={completed}, "
            f"conversation={assistant_id_key}, agent={agent_name}"
        )

        # Delete thinking indicator if it exists (before sending first response)
        if assistant_id_key in assistant_context:
            thinking_ts = assistant_context[assistant_id_key].get(THINKING_MESSAGE_ID_KEY)
            if thinking_ts:
                error_prefix = f"Failed to delete thinking indicator {thinking_ts} in {channel_id}"
                try:
                    success, response = self.delete_message(channel_id, thinking_ts)
                    if not success:
                        demisto.error(f"{error_prefix}: {response}")
                except Exception as e:
                    demisto.error(f"{error_prefix}: {e}")
                assistant_context[assistant_id_key].pop(THINKING_MESSAGE_ID_KEY, None)

        # Group messages by type category
        grouped = self._group_messages_by_type(messages)

        # Track the final status/lock state across all groups
        new_status = None
        should_release_lock = False

        for group in grouped:
            first_msg = group[0]
            message_type = first_msg.get("response_type", "")

            if AssistantMessageType.is_step_type(message_type):
                # Step-type group: merge contents using platform-specific dividers
                step_contents = [
                    self._unescape_content(msg.get("content", ""))
                    for msg in group
                    if msg.get("content", "").strip()
                ]
                if not step_contents:
                    demisto.debug("Skipping step group with all empty contents")
                    continue
                blocks, attachments = self.prepare_merged_step_blocks(step_contents)

                self.post_agent_response(
                    channel_id, thread_id, blocks, attachments, agent_name,
                    fallback_text=" | ".join(step_contents),
                )
                new_status = AssistantStatus.RESPONDING_WITH_PLAN.value
            else:
                # Non-step types: send each message individually
                for msg in group:
                    msg_content = self._unescape_content(msg.get("content", ""))
                    msg_type = msg.get("response_type", "")
                    msg_id = msg.get("message_id", "")
                    msg_is_final = msg.get("is_final", False)

                    # Skip user-type messages (echoed user messages should not be sent to the platform)
                    if msg_type == AssistantMessageType.USER.value:
                        demisto.debug("Skipping user-type message (not sent to user)")
                        continue

                    # Skip messages with empty content unless they carry UI elements (e.g., approval buttons)
                    if not msg_content.strip() and not AssistantMessageType.is_approval_type(msg_type):
                        demisto.debug(f"Skipping message with empty content (type={msg_type})")
                        continue

                    msg_metadata = msg.get("metadata") or {}

                    self._send_single_response(
                        channel_id=channel_id,
                        thread_id=thread_id,
                        message=msg_content,
                        message_type=msg_type,
                        message_id=msg_id,
                        agent_name=agent_name,
                        user_id=user_id,
                        completed=msg_is_final,
                        metadata=msg_metadata,
                    )

                    # Determine status from the last message in the group
                    if AssistantMessageType.is_model_type(msg_type):
                        if AssistantMessageType.is_approval_type(msg_type):
                            new_status = AssistantStatus.AWAITING_SENSITIVE_ACTION_APPROVAL.value
                            should_release_lock = False
                        elif msg_is_final:
                            should_release_lock = True
                    elif AssistantMessageType.is_error_type(msg_type):
                        should_release_lock = True

        # Update context based on final state
        if assistant_id_key in assistant_context:
            if should_release_lock:
                del assistant_context[assistant_id_key]
                self.update_context({self.CONTEXT_KEY: assistant_context})
            elif new_status:
                assistant_context[assistant_id_key]["status"] = new_status
                assistant_context[assistant_id_key]["last_updated"] = datetime.now(UTC).timestamp()
                self.update_context({self.CONTEXT_KEY: assistant_context})

        demisto.results("Agent response sent successfully.")
        return assistant_context

    def _send_single_response(
        self,
        channel_id: str,
        thread_id: str,
        message: str,
        message_type: AssistantMessageType,
        message_id: str,
        agent_name: str,
        user_id: str,
        completed: bool,
        metadata: dict | None = None,
    ):
        """
        Sends a single agent response message to the platform.

        Args:
            channel_id: The channel ID
            thread_id: The thread ID
            message: The message text (already unescaped)
            message_type: The message type (from AssistantMessageType)
            message_id: Optional message ID for feedback tracking
            agent_name: Optional agent name to display
            user_id: Optional user ID to mention in model and error responses
            completed: Whether this is the final response
            metadata: Optional metadata dict from the message
        """
        # Prepare blocks and attachments using platform-specific method
        blocks, attachments = self.prepare_message_blocks(message, message_type)
        if not blocks:
            blocks = []

        # Add user mention for model and error types so the user gets notified
        if user_id and completed:
            user_mention_block = {
                "type": "section",
                "text": {"type": "mrkdwn", "text": self.format_user_mention(user_id)},
            }
            blocks.insert(0, user_mention_block)

        # Add script availability notice when script_data is present in metadata
        demisto.debug(f"_send_single_response: has_metadata={bool(metadata)}, has_script_data={bool(metadata and metadata.get('script_data'))}")
        if metadata and metadata.get("script_data"):
            demisto.debug("Adding script availability notice block")
            script_notice = self.create_script_notice_ui()
            if script_notice:
                blocks.append(script_notice)

        # Handle model-specific UI elements
        if AssistantMessageType.is_model_type(message_type):
            if AssistantMessageType.is_approval_type(message_type):
                blocks.extend(self.create_approval_ui())

            if message_id:
                blocks.append(self.create_feedback_ui(message_id))

        # Send message using platform-specific method
        self.post_agent_response(
            channel_id, thread_id, blocks, attachments, agent_name, fallback_text=message
        )