fix(slack): comprehensive mrkdwn formatting — 6 bug fixes + 52 tests

Fixes blockquote > escaping, edit_message raw markdown, ***bold italic***
handling, HTML entity double-escaping (&), Wikipedia URL parens
truncation, and step numbering format. Also adds format_message to the
tool-layer _send_to_platform for consistent formatting across all
delivery paths.

Changes:
- Protect Slack entities (<@user>, <https://...|label>, <!here>) from
  escaping passes
- Protect blockquote > markers before HTML entity escaping
- Unescape-before-escape for idempotent HTML entity handling
- ***bold italic*** → *_text_* conversion (before **bold** pass)
- URL regex upgraded to handle balanced parentheses
- mrkdwn:True flag on chat_postMessage payloads
- format_message applied in edit_message and send_message_tool
- 52 new tests (format, edit, streaming, splitting, tool chunking)
- Use reversed(dict) idiom for placeholder restoration

Based on PR #3715 by dashed, cherry-picked onto current main.
This commit is contained in:
dashed
2026-04-09 13:33:05 -07:00
committed by Teknium
parent 7d499c75db
commit 7f7b02b764
4 changed files with 556 additions and 17 deletions

View File

@@ -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) → <url|text>
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('&amp;', '&').replace('&lt;', '<').replace('&gt;', '>')
text = text.replace('&', '&amp;').replace('<', '&lt;').replace('>', '&gt;')
# 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'(?<!\*)\*([^*\n]+)\*(?!\*)',
lambda m: _ph(f'_{m.group(1)}_'),
text,
)
# 7) Convert strikethrough: ~~text~~ → ~text~
# 11) Convert strikethrough: ~~text~~ → ~text~
text = re.sub(
r'~~(.+?)~~',
lambda m: _ph(f'~{m.group(1)}~'),
text,
)
# 8) Convert blockquotes: > 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

View File

@@ -619,6 +619,18 @@ class TestFormatMessage:
result = adapter.format_message("[click here](https://example.com)")
assert result == "<https://example.com|click here>"
def test_link_conversion_strips_markdown_angle_brackets(self, adapter):
result = adapter.format_message("[click here](<https://example.com>)")
assert result == "<https://example.com|click here>"
def test_escapes_control_characters(self, adapter):
result = adapter.format_message("AT&T < 5 > 3")
assert result == "AT&amp;T &lt; 5 &gt; 3"
def test_preserves_existing_slack_entities(self, adapter):
text = "Hey <@U123>, see <https://example.com|example> and <!here>"
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 &gt; 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 &amp; must not become &amp;amp;."""
assert adapter.format_message("&amp;") == "&amp;"
def test_pre_escaped_lt_not_double_escaped(self, adapter):
"""Already-escaped &lt; must not become &amp;lt;."""
assert adapter.format_message("&lt;") == "&lt;"
def test_pre_escaped_gt_not_double_escaped(self, adapter):
"""Already-escaped &gt; in plain text must not become &amp;gt;."""
assert adapter.format_message("5 &gt; 3") == "5 &gt; 3"
def test_mixed_raw_and_escaped_entities(self, adapter):
"""Raw & and pre-escaped &amp; coexist correctly."""
result = adapter.format_message("AT&T and &amp; entity")
assert result == "AT&amp;T and &amp; 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 == "<https://en.wikipedia.org/wiki/Foo_(bar)|Foo>"
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 == "<https://example.com/a_(b)_c_(d)|text>"
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 == "<https://example.com/path?q=1|click>"
def test_link_with_angle_brackets_and_parens(self, adapter):
"""Angle-bracket URL with parens (CommonMark syntax)."""
result = adapter.format_message("[Foo](<https://en.wikipedia.org/wiki/Foo_(bar)>)")
assert result == "<https://en.wikipedia.org/wiki/Foo_(bar)|Foo>"
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):
"""<!channel> special mention passes through unchanged."""
assert adapter.format_message("Attention <!channel>") == "Attention <!channel>"
def test_everyone_mention_preserved(self, adapter):
"""<!everyone> special mention passes through unchanged."""
assert adapter.format_message("Hey <!everyone>") == "Hey <!everyone>"
def test_subteam_mention_preserved(self, adapter):
"""<!subteam^ID> user group mention passes through unchanged."""
assert adapter.format_message("Paging <!subteam^S12345>") == "Paging <!subteam^S12345>"
def test_date_formatting_preserved(self, adapter):
"""<!date^...> formatting token passes through unchanged."""
text = "Posted <!date^1392734382^{date_pretty}|Feb 18, 2014>"
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 "<https://x.com|link>" 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 == "<https://x.com?a=1&b=2|link>"
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"] == "<https://example.com|click>"
@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&amp;T &lt; 5 &gt; 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 <https://example.com|results>"
@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 <https://docs.example.com|docs>",
),
]
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 &gt; 3 and &amp; entity")
kwargs = adapter._app.client.chat_update.call_args.kwargs
assert "&amp;gt;" not in kwargs["text"]
assert "&amp;amp;" not in kwargs["text"]
assert "&gt;" in kwargs["text"]
assert "&amp;" 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 "<https://en.wikipedia.org/wiki/Foo_(bar)|Foo>" 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 &amp; in sent messages must not become &amp;amp;."""
adapter._app.client.chat_postMessage = AsyncMock(return_value={"ts": "ts1"})
await adapter.send("C123", "Use &amp; for ampersand")
kwargs = adapter._app.client.chat_postMessage.call_args.kwargs
assert "&amp;amp;" not in kwargs["text"]
assert "&amp;" 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 "<https://en.wikipedia.org/wiki/Foo_(bar)|Foo>" in kwargs["text"]
# ---------------------------------------------------------------------------
# TestReplyBroadcast

View File

@@ -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](<https://example.com>)",
)
)
assert result["success"] is True
send.assert_awaited_once_with(
"***",
"C123",
"*hello* from <https://example.com|Hermes>",
)
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 '&gt;')."""
_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 "&amp;" in sent_text # & is escaped
assert "&gt;" 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&amp;T &lt;tag&gt; test",
)
)
assert result["success"] is True
sent_text = send.await_args.args[2]
assert "&amp;amp;" not in sent_text
assert "&amp;lt;" not in sent_text
assert "AT&amp;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 "<https://en.wikipedia.org/wiki/Foo_(bar)|Foo>" 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):

View File

@@ -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")}