Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
252 changes: 249 additions & 3 deletions gateway/platforms/slack.py
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,160 @@ def check_slack_requirements() -> bool:
return SLACK_AVAILABLE


def _extract_text_from_slack_blocks(blocks: list) -> str:
"""Extract readable text from Slack Block Kit blocks, including quoted/forwarded content.

Slack's modern WYSIWYG composer sends messages with a ``blocks`` array
containing ``rich_text`` elements. When a user forwards or quotes another
message, the quoted content appears as nested ``rich_text_quote`` elements
that are *not* included in the plain ``text`` field of the event.

This helper walks the rich-text tree recursively and returns readable lines,
preserving quotes, list items, and preformatted blocks so the agent can see
forwarded/quoted content instead of only the lossy plain-text field.
"""
if not blocks:
return ""

parts: list[str] = []

def _render_inline_elements(elements: list) -> str:
"""Render inline elements (text, link, channel, user, emoji, etc.)."""
pieces: list[str] = []
for el in elements:
el_type = el.get("type", "")
if el_type == "text":
pieces.append(el.get("text", ""))
elif el_type == "link":
url = el.get("url", "")
text = el.get("text", "") or url
pieces.append(f"{text} ({url})")
elif el_type == "channel":
pieces.append(f"<#{el.get('channel_id', '')}>")
elif el_type == "user":
pieces.append(f"<@{el.get('user_id', '')}>")
elif el_type == "usergroup":
pieces.append(f"<!subteam^{el.get('usergroup_id', '')}>")
elif el_type == "emoji":
pieces.append(f":{el.get('name', '')}:")
elif el_type == "broadcast":
pieces.append(f"<!{el.get('range', 'here')}>")
elif el_type == "date":
pieces.append(el.get("fallback", ""))
return "".join(pieces)

def _append_line(text: str, quote_depth: int = 0, bullet: str = "") -> None:
if not text or not text.strip():
return
prefix = ((">" * quote_depth) + " ") if quote_depth else ""
parts.append(f"{prefix}{bullet}{text}".rstrip())

def _walk_elements(elements: list, quote_depth: int = 0, bullet: str = "") -> None:
for elem in elements:
elem_type = elem.get("type", "")

if elem_type == "rich_text_section":
_append_line(
_render_inline_elements(elem.get("elements", [])),
quote_depth=quote_depth,
bullet=bullet,
)
elif elem_type == "rich_text_quote":
_walk_elements(elem.get("elements", []), quote_depth=quote_depth + 1)
elif elem_type == "rich_text_list":
list_style = elem.get("style")
for idx, item in enumerate(elem.get("elements", [])):
item_bullet = "• " if list_style == "bullet" else f"{idx + 1}. "
_walk_elements([item], quote_depth=quote_depth, bullet=item_bullet)
elif elem_type == "rich_text_preformatted":
code_lines: list[str] = []
for child in elem.get("elements", []):
child_type = child.get("type", "")
if child_type == "rich_text_section":
rendered = _render_inline_elements(child.get("elements", []))
else:
rendered = _render_inline_elements([child])
if rendered:
code_lines.append(rendered)
code_text = "\n".join(code_lines)
if code_text:
lang = elem.get("language", "")
_append_line(f"```{lang}\n{code_text}\n```", quote_depth=quote_depth, bullet=bullet)
else:
rendered = _render_inline_elements([elem])
if rendered:
_append_line(rendered, quote_depth=quote_depth, bullet=bullet)

for block in blocks:
if (block or {}).get("type") == "rich_text":
_walk_elements(block.get("elements", []))

return "\n".join(parts)


def _serialize_slack_blocks_for_agent(blocks: list, max_chars: int = 6000) -> str:
"""Return a compact, redacted JSON view of the current message's Block Kit payload."""
if not blocks:
return ""

if all((block or {}).get("type") == "rich_text" for block in blocks):
return ""

scalar_allowlist = {
"type",
"block_id",
"action_id",
"style",
"dispatch_action",
"optional",
"multiple",
"emoji",
}
recursive_allowlist = {
"text",
"title",
"description",
"label",
"placeholder",
"accessory",
"fields",
"elements",
"options",
"option_groups",
"confirm",
"submit",
"close",
"hint",
}

def _sanitize(value):
if isinstance(value, list):
return [item for item in (_sanitize(v) for v in value) if item not in (None, {}, [], "")]
if isinstance(value, dict):
sanitized = {}
for key, item in value.items():
if key in scalar_allowlist:
sanitized[key] = item
elif key in recursive_allowlist:
cleaned = _sanitize(item)
if cleaned not in (None, {}, [], ""):
sanitized[key] = cleaned
return sanitized
if isinstance(value, (str, int, float, bool)) or value is None:
return value
return repr(value)

try:
payload = json.dumps(_sanitize(blocks), ensure_ascii=False, indent=2)
except Exception:
payload = repr(blocks)

if len(payload) > max_chars:
payload = payload[: max_chars - 18].rstrip() + "\n... [truncated]"

return f"[Slack Block Kit payload for this message]\n```json\n{payload}\n```"


class SlackAdapter(BasePlatformAdapter):
"""
Slack bot adapter using Socket Mode.
Expand Down Expand Up @@ -1133,7 +1287,98 @@ async def _handle_slack_message(self, event: dict) -> None:
if subtype in ("message_changed", "message_deleted"):
return

text = event.get("text", "")
original_text = event.get("text", "")
text = original_text

# Extract quoted/forwarded content from Slack blocks.
# Slack's modern composer embeds forwarded messages in the ``blocks``
# array as ``rich_text_quote`` elements, which are NOT reflected in
# the plain ``text`` field. Merge block text so the agent sees the
# full message content.
blocks = event.get("blocks")
if blocks:
blocks_text = _extract_text_from_slack_blocks(blocks)
if blocks_text:
# Only append if the blocks contain text not already present
# in the plain text field (avoids duplication).
stripped_blocks = blocks_text.strip()
if stripped_blocks and stripped_blocks not in text.strip():
logger.debug(
"Slack: extracted additional text from blocks "
"(likely quoted/forwarded content): %s",
stripped_blocks[:300],
)
text = (text.strip() + "\n" + stripped_blocks).strip()

blocks_payload = _serialize_slack_blocks_for_agent(blocks)
if blocks_payload:
text = (text.strip() + "\n\n" + blocks_payload).strip()

# Extract link unfurls / rich attachments (e.g. Notion previews).
# Slack places unfurled link previews in the ``attachments`` array with
# fields like title, title_link/from_url, text, footer, and fallback.
# Without reading these, the agent never sees shared link previews.
slack_attachments = event.get("attachments") or []
if slack_attachments:
att_parts: list[str] = []
for att in slack_attachments:
att_title = att.get("title", "")
att_url = att.get("title_link", "") or att.get("from_url", "")
att_text = att.get("text", "")
att_footer = att.get("footer", "")
att_fallback = att.get("fallback", "")

# Skip message-type attachments (e.g. Slack bot messages with
# is_msg_unfurl) to avoid echoing our own content.
if att.get("is_msg_unfurl"):
continue

# Build a readable representation.
if att_title and att_url:
header = f"📎 [{att_title}]({att_url})"
elif att_title:
header = f"📎 {att_title}"
elif att_url:
header = f"📎 {att_url}"
else:
header = None

# Prefer preview text, fall back to fallback description.
body = att_text or att_fallback or ""
if body:
body = body.strip()
if len(body) > 500:
body = body[:497] + "..."

if header and body:
section = f"{header}\n {body}"
elif header:
section = header
elif body:
section = f"📎 {body}"
else:
continue

# Deduplicate only when the fully rendered section is already
# present. The shared URL often already appears in the user's
# message text, and skipping on URL/title alone would hide the
# preview body we actually want the agent to see.
if section in text:
continue

if att_footer:
section = f"{section}\n _{att_footer}_"

att_parts.append(section)

if att_parts:
attachment_text = "\n\n".join(att_parts)
text = (text.strip() + "\n\n" + attachment_text).strip()
logger.debug(
"Slack: appended %d link unfurl(s) to message text",
len(att_parts),
)

channel_id = event.get("channel", "")
ts = event.get("ts", "")
assistant_meta = self._lookup_assistant_thread_metadata(
Expand Down Expand Up @@ -1182,7 +1427,8 @@ async def _handle_slack_message(self, event: dict) -> None:
# 3. The message is in a thread where the bot was previously @mentioned, OR
# 4. There's an existing session for this thread (survives restarts)
bot_uid = self._team_bot_user_ids.get(team_id, self._bot_user_id)
is_mentioned = bot_uid and f"<@{bot_uid}>" in text
routing_text = original_text or ""
is_mentioned = bot_uid and f"<@{bot_uid}>" in routing_text
event_thread_ts = event.get("thread_ts")
is_thread_reply = bool(event_thread_ts and event_thread_ts != ts)

Expand Down Expand Up @@ -1244,7 +1490,7 @@ async def _handle_slack_message(self, event: dict) -> None:

# Determine message type
msg_type = MessageType.TEXT
if text.startswith("/"):
if (original_text or "").startswith("/"):
msg_type = MessageType.COMMAND

# Handle file attachments
Expand Down
5 changes: 3 additions & 2 deletions gateway/session.py
Original file line number Diff line number Diff line change
Expand Up @@ -310,8 +310,9 @@ def build_session_context_prompt(
"**Platform notes:** You are running inside Slack. "
"You do NOT have access to Slack-specific APIs — you cannot search "
"channel history, pin/unpin messages, manage channels, or list users. "
"Do not promise to perform these actions. If the user asks, explain "
"that you can only read messages sent directly to you and respond."
"Do not promise to perform these actions. The gateway may inline the "
"current message's Slack block/attachment payload when available, but "
"you still cannot call Slack APIs yourself."
)
elif context.source.platform == Platform.DISCORD:
# Inject the Discord IDs block only when the agent actually has
Expand Down
1 change: 1 addition & 0 deletions scripts/release.py
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,7 @@
"139848623+hhuang91@users.noreply.github.com": "hhuang91",
"s.ozaki@ebinou.net": "Satoshi-agi",
"10774721+kunlabs@users.noreply.github.com": "kunlabs",
"110560187+Wang-tianhao@users.noreply.github.com": "Wang-tianhao",
"nocoo@users.noreply.github.com": "nocoo",
"30841158+n-WN@users.noreply.github.com": "n-WN",
"tsuijinglei@gmail.com": "hiddenpuppy",
Expand Down
1 change: 1 addition & 0 deletions tests/gateway/test_session.py
Original file line number Diff line number Diff line change
Expand Up @@ -245,6 +245,7 @@ def test_slack_prompt_includes_platform_notes(self):
assert "Slack" in prompt
assert "cannot search" in prompt.lower()
assert "pin" in prompt.lower()
assert "current message's slack block/attachment payload" in prompt.lower()

def test_discord_prompt_with_channel_topic(self):
"""Channel topic should appear in the session context prompt."""
Expand Down
Loading
Loading