diff --git a/gateway/platforms/slack.py b/gateway/platforms/slack.py index 26282b134..b45e39066 100644 --- a/gateway/platforms/slack.py +++ b/gateway/platforms/slack.py @@ -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"") + elif el_type == "emoji": + pieces.append(f":{el.get('name', '')}:") + elif el_type == "broadcast": + pieces.append(f"") + 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. @@ -1133,7 +1287,98 @@ class SlackAdapter(BasePlatformAdapter): 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( @@ -1182,7 +1427,8 @@ class SlackAdapter(BasePlatformAdapter): # 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) @@ -1244,7 +1490,7 @@ class SlackAdapter(BasePlatformAdapter): # Determine message type msg_type = MessageType.TEXT - if text.startswith("/"): + if (original_text or "").startswith("/"): msg_type = MessageType.COMMAND # Handle file attachments diff --git a/gateway/session.py b/gateway/session.py index 7e4604c0d..d693945d9 100644 --- a/gateway/session.py +++ b/gateway/session.py @@ -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 diff --git a/tests/gateway/test_session.py b/tests/gateway/test_session.py index deeb55940..228f414a0 100644 --- a/tests/gateway/test_session.py +++ b/tests/gateway/test_session.py @@ -245,6 +245,7 @@ class TestBuildSessionContextPrompt: 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.""" diff --git a/tests/gateway/test_slack.py b/tests/gateway/test_slack.py index e57800618..3de2b0af3 100644 --- a/tests/gateway/test_slack.py +++ b/tests/gateway/test_slack.py @@ -355,15 +355,17 @@ class TestSendVideo: # --------------------------------------------------------------------------- class TestIncomingDocumentHandling: - def _make_event(self, files=None, text="hello", channel_type="im"): + def _make_event(self, files=None, text="hello", channel_type="im", blocks=None, attachments=None): """Build a mock Slack message event with file attachments.""" return { "text": text, "user": "U_USER", - "channel": "C123", + "channel": "D123", "channel_type": channel_type, "ts": "1234567890.000001", "files": files or [], + "blocks": blocks or [], + "attachments": attachments or [], } @pytest.mark.asyncio @@ -540,6 +542,178 @@ class TestIncomingDocumentHandling: assert "403" in msg_event.text assert "what's in this?" in msg_event.text + @pytest.mark.asyncio + async def test_rich_text_blocks_do_not_duplicate_plain_text(self, adapter): + """Plain rich_text composer blocks match the plain text field exactly, + so the dedupe guard keeps the message clean.""" + event = self._make_event( + text="hello world", + blocks=[ + { + "type": "rich_text", + "elements": [ + { + "type": "rich_text_section", + "elements": [ + {"type": "text", "text": "hello world"}, + ], + } + ], + } + ], + ) + + await adapter._handle_slack_message(event) + + msg_event = adapter.handle_message.call_args[0][0] + assert msg_event.text == "hello world" + + @pytest.mark.asyncio + async def test_rich_text_quotes_and_lists_are_extracted(self, adapter): + """Nested quote and list content should be surfaced from rich_text blocks.""" + event = self._make_event( + text="Can you summarize this?", + blocks=[ + { + "type": "rich_text", + "elements": [ + { + "type": "rich_text_quote", + "elements": [ + { + "type": "rich_text_section", + "elements": [{"type": "text", "text": "Quoted line"}], + } + ], + }, + { + "type": "rich_text_list", + "style": "bullet", + "elements": [ + { + "type": "rich_text_section", + "elements": [{"type": "text", "text": "First bullet"}], + }, + { + "type": "rich_text_section", + "elements": [{"type": "text", "text": "Second bullet"}], + }, + ], + }, + ], + } + ], + ) + + await adapter._handle_slack_message(event) + + msg_event = adapter.handle_message.call_args[0][0] + assert "Can you summarize this?" in msg_event.text + assert "> Quoted line" in msg_event.text + assert "• First bullet" in msg_event.text + assert "• Second bullet" in msg_event.text + + @pytest.mark.asyncio + async def test_attachments_unfurl_text_is_appended_even_when_url_is_in_message(self, adapter): + """Shared URLs should still expose unfurl preview text to the agent.""" + event = self._make_event( + text="Look at this doc https://example.com/spec", + attachments=[ + { + "title": "Spec", + "from_url": "https://example.com/spec", + "text": "The latest product spec preview", + "footer": "Notion", + } + ], + ) + + await adapter._handle_slack_message(event) + + msg_event = adapter.handle_message.call_args[0][0] + assert "Look at this doc https://example.com/spec" in msg_event.text + assert "📎 [Spec](https://example.com/spec)" in msg_event.text + assert "The latest product spec preview" in msg_event.text + assert "_Notion_" in msg_event.text + + @pytest.mark.asyncio + async def test_message_unfurl_attachments_are_skipped(self, adapter): + """Message unfurls should be skipped to avoid echoing Slack message copies.""" + event = self._make_event( + text="https://example.com/thread", + attachments=[ + { + "is_msg_unfurl": True, + "title": "Thread copy", + "text": "This should not be appended", + } + ], + ) + + await adapter._handle_slack_message(event) + + msg_event = adapter.handle_message.call_args[0][0] + assert msg_event.text == "https://example.com/thread" + + @pytest.mark.asyncio + async def test_channel_routing_ignores_bot_mentions_inside_block_text(self, adapter): + """Block-extracted text with a bot mention must not satisfy mention + gating in channels — routing decisions use the original user text so + quoted/forwarded content can't trick the bot into responding.""" + event = self._make_event( + text="please review", + channel_type="channel", + blocks=[ + { + "type": "rich_text", + "elements": [ + { + "type": "rich_text_quote", + "elements": [ + { + "type": "rich_text_section", + "elements": [{"type": "text", "text": "Contains <@U_BOT> in quoted text"}], + } + ], + } + ], + } + ], + ) + + await adapter._handle_slack_message(event) + + adapter.handle_message.assert_not_called() + + @pytest.mark.asyncio + async def test_quoted_slash_command_text_does_not_change_message_type(self, adapter): + """Quoted slash-like content should not convert a normal message into a command.""" + event = self._make_event( + text="", + blocks=[ + { + "type": "rich_text", + "elements": [ + { + "type": "rich_text_quote", + "elements": [ + { + "type": "rich_text_section", + "elements": [{"type": "text", "text": "/deploy now"}], + } + ], + } + ], + } + ], + ) + + await adapter._handle_slack_message(event) + + msg_event = adapter.handle_message.call_args[0][0] + assert msg_event.message_type == MessageType.TEXT + assert "> /deploy now" in msg_event.text + # --------------------------------------------------------------------------- # TestMessageRouting