SlackUtilsApiModule
Slack-specific utilities for Cortex Assistant integration. Handles Slack Block Kit formatting, message parsing, and UI element creation.
- Type
- python
- Pack
- 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 underscores are boundary-aware: a delimiter adjacent to a word char is NOT emphasis,
# so intraword underscores (e.g. "run__script", "first_name") stay literal.
ITALIC_DOUBLE_PATTERN = r"(?<!\w)__(?!\s).+?(?<!\s)__(?!\w)" # __text__
ITALIC_SINGLE_PATTERN = r"(?<!\w)_(?!\s).+?(?<!\s)_(?!\w)" # _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.fullmatch(ITALIC_DOUBLE_PATTERN, part):
content = part[2:-2] # Remove __ from both sides
style["italic"] = True
elif re.fullmatch(ITALIC_SINGLE_PATTERN, part):
content = part[1:-1] # Remove _ from both sides
style["italic"] = True
# Empty delimiters (e.g. "__"/"****") -> keep the original chars as literal text; Slack rejects an empty "text".
if not content:
content = part
style = {}
# 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,
)