fix(slack): comprehensive mrkdwn formatting — 6 bug fixes + 52 tests
Fixes blockquote > escaping, edit_message raw markdown, ***bold italic*** handling, HTML entity double-escaping (&amp;), 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:
@@ -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('&', '&').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'(?<!\*)\*([^*\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
|
||||
|
||||
@@ -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&T < 5 > 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 > 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 == "<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&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 <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 > 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 "<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 & 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 "<https://en.wikipedia.org/wiki/Foo_(bar)|Foo>" in kwargs["text"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TestReplyBroadcast
|
||||
|
||||
@@ -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 '>')."""
|
||||
_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 "<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):
|
||||
|
||||
@@ -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")}
|
||||
|
||||
Reference in New Issue
Block a user