diff --git a/gateway/platforms/slack.py b/gateway/platforms/slack.py index 6a5471d59ae..d2f50907217 100644 --- a/gateway/platforms/slack.py +++ b/gateway/platforms/slack.py @@ -281,6 +281,7 @@ class SlackAdapter(BasePlatformAdapter): kwargs = { "channel": chat_id, "text": chunk, + "mrkdwn": True, } if thread_ts: kwargs["thread_ts"] = thread_ts @@ -323,9 +324,7 @@ class SlackAdapter(BasePlatformAdapter): if not self._app: return SendResult(success=False, error="Not connected") try: - # Convert standard markdown → Slack mrkdwn formatted = self.format_message(content) - await self._get_client(chat_id).chat_update( channel=chat_id, ts=message_id, @@ -457,13 +456,36 @@ class SlackAdapter(BasePlatformAdapter): text = re.sub(r'(`[^`]+`)', lambda m: _ph(m.group(0)), text) # 3) Convert markdown links [text](url) → + def _convert_markdown_link(m): + label = m.group(1) + url = m.group(2).strip() + if url.startswith('<') and url.endswith('>'): + url = url[1:-1].strip() + return _ph(f'<{url}|{label}>') + text = re.sub( - r'\[([^\]]+)\]\(([^)]+)\)', - lambda m: _ph(f'<{m.group(2)}|{m.group(1)}>'), + r'\[([^\]]+)\]\(([^()]*(?:\([^()]*\)[^()]*)*)\)', + _convert_markdown_link, text, ) - # 4) Convert headers (## Title) → *Title* (bold) + # 4) Protect existing Slack entities/manual links so escaping and later + # formatting passes don't break them. + text = re.sub( + r'(<(?:[@#!]|(?:https?|mailto|tel):)[^>\n]+>)', + lambda m: _ph(m.group(1)), + text, + ) + + # 5) Protect blockquote markers before escaping + text = re.sub(r'^(>+\s)', lambda m: _ph(m.group(0)), text, flags=re.MULTILINE) + + # 6) Escape Slack control characters in remaining plain text. + # Unescape first so already-escaped input doesn't get double-escaped. + text = text.replace('&', '&').replace('<', '<').replace('>', '>') + text = text.replace('&', '&').replace('<', '<').replace('>', '>') + + # 7) Convert headers (## Title) → *Title* (bold) def _convert_header(m): inner = m.group(1).strip() # Strip redundant bold markers inside a header @@ -474,34 +496,39 @@ class SlackAdapter(BasePlatformAdapter): r'^#{1,6}\s+(.+)$', _convert_header, text, flags=re.MULTILINE ) - # 5) Convert bold: **text** → *text* (Slack bold) + # 8) Convert bold+italic: ***text*** → *_text_* (Slack bold wrapping italic) + text = re.sub( + r'\*\*\*(.+?)\*\*\*', + lambda m: _ph(f'*_{m.group(1)}_*'), + text, + ) + + # 9) Convert bold: **text** → *text* (Slack bold) text = re.sub( r'\*\*(.+?)\*\*', lambda m: _ph(f'*{m.group(1)}*'), text, ) - # 6) Convert italic: _text_ stays as _text_ (already Slack italic) - # Single *text* → _text_ (Slack italic) + # 10) Convert italic: _text_ stays as _text_ (already Slack italic) + # Single *text* → _text_ (Slack italic) text = re.sub( r'(? text → > text (same syntax, just ensure - # no extra escaping happens to the > character) - # Slack uses the same > prefix, so this is a no-op for content. + # 12) Blockquotes: > prefix is already protected by step 5 above. - # 9) Restore placeholders in reverse order - for key in reversed(list(placeholders.keys())): + # 13) Restore placeholders in reverse order + for key in reversed(placeholders): text = text.replace(key, placeholders[key]) return text diff --git a/tests/gateway/test_slack.py b/tests/gateway/test_slack.py index 67c7cce1dce..983a7e990cc 100644 --- a/tests/gateway/test_slack.py +++ b/tests/gateway/test_slack.py @@ -619,6 +619,18 @@ class TestFormatMessage: result = adapter.format_message("[click here](https://example.com)") assert result == "" + def test_link_conversion_strips_markdown_angle_brackets(self, adapter): + result = adapter.format_message("[click here]()") + assert result == "" + + def test_escapes_control_characters(self, adapter): + result = adapter.format_message("AT&T < 5 > 3") + assert result == "AT&T < 5 > 3" + + def test_preserves_existing_slack_entities(self, adapter): + text = "Hey <@U123>, see and " + assert adapter.format_message(text) == text + def test_strikethrough(self, adapter): assert adapter.format_message("~~deleted~~") == "~deleted~" @@ -643,6 +655,325 @@ class TestFormatMessage: def test_none_passthrough(self, adapter): assert adapter.format_message(None) is None + def test_blockquote_preserved(self, adapter): + """Single-line blockquote > marker is preserved.""" + assert adapter.format_message("> quoted text") == "> quoted text" + + def test_multiline_blockquote(self, adapter): + """Multi-line blockquote preserves > on each line.""" + text = "> line one\n> line two" + assert adapter.format_message(text) == "> line one\n> line two" + + def test_blockquote_with_formatting(self, adapter): + """Blockquote containing bold text.""" + assert adapter.format_message("> **bold quote**") == "> *bold quote*" + + def test_nested_blockquote(self, adapter): + """Multiple > characters for nested quotes.""" + assert adapter.format_message(">> deeply quoted") == ">> deeply quoted" + + def test_blockquote_mixed_with_plain(self, adapter): + """Blockquote lines interleaved with plain text.""" + text = "normal\n> quoted\nnormal again" + result = adapter.format_message(text) + assert "> quoted" in result + assert "normal" in result + + def test_non_prefix_gt_still_escaped(self, adapter): + """Greater-than in mid-line is still escaped.""" + assert adapter.format_message("5 > 3") == "5 > 3" + + def test_blockquote_with_code(self, adapter): + """Blockquote containing inline code.""" + result = adapter.format_message("> use `fmt.Println`") + assert result.startswith(">") + assert "`fmt.Println`" in result + + def test_bold_italic_combined(self, adapter): + """Triple-star ***text*** converts to Slack bold+italic *_text_*.""" + assert adapter.format_message("***hello***") == "*_hello_*" + + def test_bold_italic_with_surrounding_text(self, adapter): + """Bold+italic in a sentence.""" + result = adapter.format_message("This is ***important*** stuff") + assert "*_important_*" in result + + def test_bold_italic_does_not_break_plain_bold(self, adapter): + """**bold** still works after adding ***bold italic*** support.""" + assert adapter.format_message("**bold**") == "*bold*" + + def test_bold_italic_does_not_break_plain_italic(self, adapter): + """*italic* still works after adding ***bold italic*** support.""" + assert adapter.format_message("*italic*") == "_italic_" + + def test_bold_italic_mixed_with_bold(self, adapter): + """Both ***bold italic*** and **bold** in the same message.""" + result = adapter.format_message("***important*** and **bold**") + assert "*_important_*" in result + assert "*bold*" in result + + def test_pre_escaped_ampersand_not_double_escaped(self, adapter): + """Already-escaped & must not become &amp;.""" + assert adapter.format_message("&") == "&" + + def test_pre_escaped_lt_not_double_escaped(self, adapter): + """Already-escaped < must not become &lt;.""" + assert adapter.format_message("<") == "<" + + def test_pre_escaped_gt_not_double_escaped(self, adapter): + """Already-escaped > in plain text must not become &gt;.""" + assert adapter.format_message("5 > 3") == "5 > 3" + + def test_mixed_raw_and_escaped_entities(self, adapter): + """Raw & and pre-escaped & coexist correctly.""" + result = adapter.format_message("AT&T and & entity") + assert result == "AT&T and & entity" + + def test_link_with_parentheses_in_url(self, adapter): + """Wikipedia-style URL with balanced parens is not truncated.""" + result = adapter.format_message("[Foo](https://en.wikipedia.org/wiki/Foo_(bar))") + assert result == "" + + def test_link_with_multiple_paren_pairs(self, adapter): + """URL with multiple balanced paren pairs.""" + result = adapter.format_message("[text](https://example.com/a_(b)_c_(d))") + assert result == "" + + def test_link_without_parens_still_works(self, adapter): + """Normal URL without parens is unaffected by regex change.""" + result = adapter.format_message("[click](https://example.com/path?q=1)") + assert result == "" + + def test_link_with_angle_brackets_and_parens(self, adapter): + """Angle-bracket URL with parens (CommonMark syntax).""" + result = adapter.format_message("[Foo]()") + assert result == "" + + def test_escaping_is_idempotent(self, adapter): + """Formatting already-formatted text produces the same result.""" + original = "AT&T < 5 > 3" + once = adapter.format_message(original) + twice = adapter.format_message(once) + assert once == twice + + # --- Entity preservation (spec-compliance) --- + + def test_channel_mention_preserved(self, adapter): + """ special mention passes through unchanged.""" + assert adapter.format_message("Attention ") == "Attention " + + def test_everyone_mention_preserved(self, adapter): + """ special mention passes through unchanged.""" + assert adapter.format_message("Hey ") == "Hey " + + def test_subteam_mention_preserved(self, adapter): + """ user group mention passes through unchanged.""" + assert adapter.format_message("Paging ") == "Paging " + + def test_date_formatting_preserved(self, adapter): + """ formatting token passes through unchanged.""" + text = "Posted " + assert adapter.format_message(text) == text + + def test_channel_link_preserved(self, adapter): + """<#CHANNEL_ID> channel link passes through unchanged.""" + assert adapter.format_message("Join <#C12345>") == "Join <#C12345>" + + # --- Additional edge cases --- + + def test_message_only_code_block(self, adapter): + """Entire message is a fenced code block — no conversion.""" + code = "```python\nx = 1\n```" + assert adapter.format_message(code) == code + + def test_multiline_mixed_formatting(self, adapter): + """Multi-line message with headers, bold, links, code, and blockquotes.""" + text = "## Title\n**bold** and [link](https://x.com)\n> quote\n`code`" + result = adapter.format_message(text) + assert result.startswith("*Title*") + assert "*bold*" in result + assert "" in result + assert "> quote" in result + assert "`code`" in result + + def test_markdown_unordered_list_with_asterisk(self, adapter): + """Asterisk list items must not trigger italic conversion.""" + text = "* item one\n* item two" + result = adapter.format_message(text) + assert "item one" in result + assert "item two" in result + + def test_nested_bold_in_link(self, adapter): + """Bold inside link label — label is stashed before bold pass.""" + result = adapter.format_message("[**bold**](https://example.com)") + assert "https://example.com" in result + assert "bold" in result + + def test_url_with_query_string_and_ampersand(self, adapter): + """Ampersand in URL query string must not be escaped.""" + result = adapter.format_message("[link](https://x.com?a=1&b=2)") + assert result == "" + + def test_emoji_shortcodes_passthrough(self, adapter): + """Emoji shortcodes like :smile: pass through unchanged.""" + assert adapter.format_message(":smile: hello :wave:") == ":smile: hello :wave:" + + +# --------------------------------------------------------------------------- +# TestEditMessage +# --------------------------------------------------------------------------- + + +class TestEditMessage: + """Verify that edit_message() applies mrkdwn formatting before sending.""" + + @pytest.mark.asyncio + async def test_edit_message_formats_bold(self, adapter): + """edit_message converts **bold** to Slack *bold*.""" + adapter._app.client.chat_update = AsyncMock(return_value={"ok": True}) + await adapter.edit_message("C123", "1234.5678", "**hello world**") + kwargs = adapter._app.client.chat_update.call_args.kwargs + assert kwargs["text"] == "*hello world*" + + @pytest.mark.asyncio + async def test_edit_message_formats_links(self, adapter): + """edit_message converts markdown links to Slack format.""" + adapter._app.client.chat_update = AsyncMock(return_value={"ok": True}) + await adapter.edit_message("C123", "1234.5678", "[click](https://example.com)") + kwargs = adapter._app.client.chat_update.call_args.kwargs + assert kwargs["text"] == "" + + @pytest.mark.asyncio + async def test_edit_message_preserves_blockquotes(self, adapter): + """edit_message preserves blockquote > markers.""" + adapter._app.client.chat_update = AsyncMock(return_value={"ok": True}) + await adapter.edit_message("C123", "1234.5678", "> quoted text") + kwargs = adapter._app.client.chat_update.call_args.kwargs + assert kwargs["text"] == "> quoted text" + + @pytest.mark.asyncio + async def test_edit_message_escapes_control_chars(self, adapter): + """edit_message escapes & < > in plain text.""" + adapter._app.client.chat_update = AsyncMock(return_value={"ok": True}) + await adapter.edit_message("C123", "1234.5678", "AT&T < 5 > 3") + kwargs = adapter._app.client.chat_update.call_args.kwargs + assert kwargs["text"] == "AT&T < 5 > 3" + + +# --------------------------------------------------------------------------- +# TestEditMessageStreamingPipeline +# --------------------------------------------------------------------------- + + +class TestEditMessageStreamingPipeline: + """E2E: verify that sequential streaming edits all go through format_message. + + Simulates the GatewayStreamConsumer pattern where edit_message is called + repeatedly with progressively longer accumulated text. Every call must + produce properly formatted mrkdwn in the chat_update payload. + """ + + @pytest.mark.asyncio + async def test_edit_message_formats_streaming_updates(self, adapter): + """Simulates streaming: multiple edits, each should be formatted.""" + adapter._app.client.chat_update = AsyncMock(return_value={"ok": True}) + + # First streaming update — bold + result1 = await adapter.edit_message("C123", "ts1", "**Processing**...") + assert result1.success is True + kwargs1 = adapter._app.client.chat_update.call_args.kwargs + assert kwargs1["text"] == "*Processing*..." + + # Second streaming update — bold + link + result2 = await adapter.edit_message( + "C123", "ts1", "**Done!** See [results](https://example.com)" + ) + assert result2.success is True + kwargs2 = adapter._app.client.chat_update.call_args.kwargs + assert kwargs2["text"] == "*Done!* See " + + @pytest.mark.asyncio + async def test_edit_message_formats_code_and_bold(self, adapter): + """Streaming update with code block and bold — code must be preserved.""" + adapter._app.client.chat_update = AsyncMock(return_value={"ok": True}) + + content = "**Result:**\n```python\nprint('hello')\n```" + result = await adapter.edit_message("C123", "ts1", content) + assert result.success is True + kwargs = adapter._app.client.chat_update.call_args.kwargs + assert kwargs["text"].startswith("*Result:*") + assert "```python\nprint('hello')\n```" in kwargs["text"] + + @pytest.mark.asyncio + async def test_edit_message_formats_blockquote_in_stream(self, adapter): + """Streaming update with blockquote — '>' marker must survive.""" + adapter._app.client.chat_update = AsyncMock(return_value={"ok": True}) + + content = "> **Important:** do this\nnormal line" + result = await adapter.edit_message("C123", "ts1", content) + assert result.success is True + kwargs = adapter._app.client.chat_update.call_args.kwargs + assert kwargs["text"].startswith("> *Important:*") + assert "normal line" in kwargs["text"] + + @pytest.mark.asyncio + async def test_edit_message_formats_progressive_accumulation(self, adapter): + """Simulate real streaming: text grows with each edit, all formatted.""" + adapter._app.client.chat_update = AsyncMock(return_value={"ok": True}) + + updates = [ + ("**Step 1**", "*Step 1*"), + ("**Step 1**\n**Step 2**", "*Step 1*\n*Step 2*"), + ( + "**Step 1**\n**Step 2**\nSee [docs](https://docs.example.com)", + "*Step 1*\n*Step 2*\nSee ", + ), + ] + + for raw, expected in updates: + result = await adapter.edit_message("C123", "ts1", raw) + assert result.success is True + kwargs = adapter._app.client.chat_update.call_args.kwargs + assert kwargs["text"] == expected, f"Failed for input: {raw!r}" + + # Total edit count should match number of updates + assert adapter._app.client.chat_update.call_count == len(updates) + + @pytest.mark.asyncio + async def test_edit_message_formats_bold_italic(self, adapter): + """Bold+italic ***text*** is formatted as *_text_* in edited messages.""" + adapter._app.client.chat_update = AsyncMock(return_value={"ok": True}) + await adapter.edit_message("C123", "ts1", "***important*** update") + kwargs = adapter._app.client.chat_update.call_args.kwargs + assert "*_important_*" in kwargs["text"] + + @pytest.mark.asyncio + async def test_edit_message_does_not_double_escape(self, adapter): + """Pre-escaped entities in edited messages must not get double-escaped.""" + adapter._app.client.chat_update = AsyncMock(return_value={"ok": True}) + await adapter.edit_message("C123", "ts1", "5 > 3 and & entity") + kwargs = adapter._app.client.chat_update.call_args.kwargs + assert "&gt;" not in kwargs["text"] + assert "&amp;" not in kwargs["text"] + assert ">" in kwargs["text"] + assert "&" in kwargs["text"] + + @pytest.mark.asyncio + async def test_edit_message_formats_url_with_parens(self, adapter): + """Wikipedia-style URL with parens survives edit pipeline.""" + adapter._app.client.chat_update = AsyncMock(return_value={"ok": True}) + await adapter.edit_message("C123", "ts1", "See [Foo](https://en.wikipedia.org/wiki/Foo_(bar))") + kwargs = adapter._app.client.chat_update.call_args.kwargs + assert "" in kwargs["text"] + + @pytest.mark.asyncio + async def test_edit_message_not_connected(self, adapter): + """edit_message returns failure when adapter is not connected.""" + adapter._app = None + result = await adapter.edit_message("C123", "ts1", "**hello**") + assert result.success is False + assert "Not connected" in result.error + # --------------------------------------------------------------------------- # TestReactions @@ -1085,6 +1416,48 @@ class TestMessageSplitting: await adapter.send("C123", "hello world") assert adapter._app.client.chat_postMessage.call_count == 1 + @pytest.mark.asyncio + async def test_send_preserves_blockquote_formatting(self, adapter): + """Blockquote '>' markers must survive format → chunk → send pipeline.""" + adapter._app.client.chat_postMessage = AsyncMock(return_value={"ts": "ts1"}) + await adapter.send("C123", "> quoted text\nnormal text") + kwargs = adapter._app.client.chat_postMessage.call_args.kwargs + sent_text = kwargs["text"] + assert sent_text.startswith("> quoted text") + assert "normal text" in sent_text + + @pytest.mark.asyncio + async def test_send_formats_bold_italic(self, adapter): + """Bold+italic ***text*** is formatted as *_text_* in sent messages.""" + adapter._app.client.chat_postMessage = AsyncMock(return_value={"ts": "ts1"}) + await adapter.send("C123", "***important*** update") + kwargs = adapter._app.client.chat_postMessage.call_args.kwargs + assert "*_important_*" in kwargs["text"] + + @pytest.mark.asyncio + async def test_send_explicitly_enables_mrkdwn(self, adapter): + adapter._app.client.chat_postMessage = AsyncMock(return_value={"ts": "ts1"}) + await adapter.send("C123", "**hello**") + kwargs = adapter._app.client.chat_postMessage.call_args.kwargs + assert kwargs.get("mrkdwn") is True + + @pytest.mark.asyncio + async def test_send_does_not_double_escape_entities(self, adapter): + """Pre-escaped & in sent messages must not become &amp;.""" + adapter._app.client.chat_postMessage = AsyncMock(return_value={"ts": "ts1"}) + await adapter.send("C123", "Use & for ampersand") + kwargs = adapter._app.client.chat_postMessage.call_args.kwargs + assert "&amp;" not in kwargs["text"] + assert "&" in kwargs["text"] + + @pytest.mark.asyncio + async def test_send_formats_url_with_parens(self, adapter): + """Wikipedia-style URL with parens survives send pipeline.""" + adapter._app.client.chat_postMessage = AsyncMock(return_value={"ts": "ts1"}) + await adapter.send("C123", "See [Foo](https://en.wikipedia.org/wiki/Foo_(bar))") + kwargs = adapter._app.client.chat_postMessage.call_args.kwargs + assert "" in kwargs["text"] + # --------------------------------------------------------------------------- # TestReplyBroadcast diff --git a/tests/tools/test_send_message_tool.py b/tests/tools/test_send_message_tool.py index 34cea278d75..94370e4d5b8 100644 --- a/tests/tools/test_send_message_tool.py +++ b/tests/tools/test_send_message_tool.py @@ -32,6 +32,30 @@ def _install_telegram_mock(monkeypatch, bot): monkeypatch.setitem(sys.modules, "telegram.constants", constants_mod) +def _ensure_slack_mock(monkeypatch): + if "slack_bolt" in sys.modules and hasattr(sys.modules["slack_bolt"], "__file__"): + return + + slack_bolt = MagicMock() + slack_bolt.async_app.AsyncApp = MagicMock + slack_bolt.adapter.socket_mode.async_handler.AsyncSocketModeHandler = MagicMock + + slack_sdk = MagicMock() + slack_sdk.web.async_client.AsyncWebClient = MagicMock + + for name, mod in [ + ("slack_bolt", slack_bolt), + ("slack_bolt.async_app", slack_bolt.async_app), + ("slack_bolt.adapter", slack_bolt.adapter), + ("slack_bolt.adapter.socket_mode", slack_bolt.adapter.socket_mode), + ("slack_bolt.adapter.socket_mode.async_handler", slack_bolt.adapter.socket_mode.async_handler), + ("slack_sdk", slack_sdk), + ("slack_sdk.web", slack_sdk.web), + ("slack_sdk.web.async_client", slack_sdk.web.async_client), + ]: + monkeypatch.setitem(sys.modules, name, mod) + + class TestSendMessageTool: def test_cron_duplicate_target_is_skipped_and_explained(self): home = SimpleNamespace(chat_id="-1001") @@ -426,7 +450,7 @@ class TestSendToPlatformChunking: result = asyncio.run( _send_to_platform( Platform.DISCORD, - SimpleNamespace(enabled=True, token="tok", extra={}), + SimpleNamespace(enabled=True, token="***", extra={}), "ch", long_msg, ) ) @@ -435,8 +459,115 @@ class TestSendToPlatformChunking: for call in send.await_args_list: assert len(call.args[2]) <= 2020 # each chunk fits the limit + def test_slack_messages_are_formatted_before_send(self, monkeypatch): + _ensure_slack_mock(monkeypatch) + + import gateway.platforms.slack as slack_mod + + monkeypatch.setattr(slack_mod, "SLACK_AVAILABLE", True) + send = AsyncMock(return_value={"success": True, "message_id": "1"}) + + with patch("tools.send_message_tool._send_slack", send): + result = asyncio.run( + _send_to_platform( + Platform.SLACK, + SimpleNamespace(enabled=True, token="***", extra={}), + "C123", + "**hello** from [Hermes]()", + ) + ) + + assert result["success"] is True + send.assert_awaited_once_with( + "***", + "C123", + "*hello* from ", + ) + + def test_slack_bold_italic_formatted_before_send(self, monkeypatch): + """Bold+italic ***text*** survives tool-layer formatting.""" + _ensure_slack_mock(monkeypatch) + import gateway.platforms.slack as slack_mod + + monkeypatch.setattr(slack_mod, "SLACK_AVAILABLE", True) + send = AsyncMock(return_value={"success": True, "message_id": "1"}) + with patch("tools.send_message_tool._send_slack", send): + result = asyncio.run( + _send_to_platform( + Platform.SLACK, + SimpleNamespace(enabled=True, token="***", extra={}), + "C123", + "***important*** update", + ) + ) + assert result["success"] is True + sent_text = send.await_args.args[2] + assert "*_important_*" in sent_text + + def test_slack_blockquote_formatted_before_send(self, monkeypatch): + """Blockquote '>' markers must survive formatting (not escaped to '>').""" + _ensure_slack_mock(monkeypatch) + import gateway.platforms.slack as slack_mod + + monkeypatch.setattr(slack_mod, "SLACK_AVAILABLE", True) + send = AsyncMock(return_value={"success": True, "message_id": "1"}) + with patch("tools.send_message_tool._send_slack", send): + result = asyncio.run( + _send_to_platform( + Platform.SLACK, + SimpleNamespace(enabled=True, token="***", extra={}), + "C123", + "> important quote\n\nnormal text & stuff", + ) + ) + assert result["success"] is True + sent_text = send.await_args.args[2] + assert sent_text.startswith("> important quote") + assert "&" in sent_text # & is escaped + assert ">" not in sent_text.split("\n")[0] # > in blockquote is NOT escaped + + def test_slack_pre_escaped_entities_not_double_escaped(self, monkeypatch): + """Pre-escaped HTML entities survive tool-layer formatting without double-escaping.""" + _ensure_slack_mock(monkeypatch) + import gateway.platforms.slack as slack_mod + monkeypatch.setattr(slack_mod, "SLACK_AVAILABLE", True) + send = AsyncMock(return_value={"success": True, "message_id": "1"}) + with patch("tools.send_message_tool._send_slack", send): + result = asyncio.run( + _send_to_platform( + Platform.SLACK, + SimpleNamespace(enabled=True, token="***", extra={}), + "C123", + "AT&T <tag> test", + ) + ) + assert result["success"] is True + sent_text = send.await_args.args[2] + assert "&amp;" not in sent_text + assert "&lt;" not in sent_text + assert "AT&T" in sent_text + + def test_slack_url_with_parens_formatted_before_send(self, monkeypatch): + """Wikipedia-style URL with parens survives tool-layer formatting.""" + _ensure_slack_mock(monkeypatch) + import gateway.platforms.slack as slack_mod + monkeypatch.setattr(slack_mod, "SLACK_AVAILABLE", True) + send = AsyncMock(return_value={"success": True, "message_id": "1"}) + with patch("tools.send_message_tool._send_slack", send): + result = asyncio.run( + _send_to_platform( + Platform.SLACK, + SimpleNamespace(enabled=True, token="***", extra={}), + "C123", + "See [Foo](https://en.wikipedia.org/wiki/Foo_(bar))", + ) + ) + assert result["success"] is True + sent_text = send.await_args.args[2] + assert "" in sent_text + def test_telegram_media_attaches_to_last_chunk(self): - """When chunked, media files are sent only with the last chunk.""" + sent_calls = [] async def fake_send(token, chat_id, message, media_files=None, thread_id=None): diff --git a/tools/send_message_tool.py b/tools/send_message_tool.py index 76b3e158205..4957609ef54 100644 --- a/tools/send_message_tool.py +++ b/tools/send_message_tool.py @@ -322,6 +322,13 @@ async def _send_to_platform(platform, pconfig, chat_id, message, thread_id=None, media_files = media_files or [] + if platform == Platform.SLACK and message: + try: + slack_adapter = SlackAdapter.__new__(SlackAdapter) + message = slack_adapter.format_message(message) + except Exception: + logger.debug("Failed to apply Slack mrkdwn formatting in _send_to_platform", exc_info=True) + # Platform message length limits (from adapter class attributes) _MAX_LENGTHS = { Platform.TELEGRAM: TelegramAdapter.MAX_MESSAGE_LENGTH, @@ -571,7 +578,8 @@ async def _send_slack(token, chat_id, message): url = "https://slack.com/api/chat.postMessage" headers = {"Authorization": f"Bearer {token}", "Content-Type": "application/json"} async with aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=30)) as session: - async with session.post(url, headers=headers, json={"channel": chat_id, "text": message}) as resp: + payload = {"channel": chat_id, "text": message, "mrkdwn": True} + async with session.post(url, headers=headers, json=payload) as resp: data = await resp.json() if data.get("ok"): return {"success": True, "platform": "slack", "chat_id": chat_id, "message_id": data.get("ts")}