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 = {
|
kwargs = {
|
||||||
"channel": chat_id,
|
"channel": chat_id,
|
||||||
"text": chunk,
|
"text": chunk,
|
||||||
|
"mrkdwn": True,
|
||||||
}
|
}
|
||||||
if thread_ts:
|
if thread_ts:
|
||||||
kwargs["thread_ts"] = thread_ts
|
kwargs["thread_ts"] = thread_ts
|
||||||
@@ -323,9 +324,7 @@ class SlackAdapter(BasePlatformAdapter):
|
|||||||
if not self._app:
|
if not self._app:
|
||||||
return SendResult(success=False, error="Not connected")
|
return SendResult(success=False, error="Not connected")
|
||||||
try:
|
try:
|
||||||
# Convert standard markdown → Slack mrkdwn
|
|
||||||
formatted = self.format_message(content)
|
formatted = self.format_message(content)
|
||||||
|
|
||||||
await self._get_client(chat_id).chat_update(
|
await self._get_client(chat_id).chat_update(
|
||||||
channel=chat_id,
|
channel=chat_id,
|
||||||
ts=message_id,
|
ts=message_id,
|
||||||
@@ -457,13 +456,36 @@ class SlackAdapter(BasePlatformAdapter):
|
|||||||
text = re.sub(r'(`[^`]+`)', lambda m: _ph(m.group(0)), text)
|
text = re.sub(r'(`[^`]+`)', lambda m: _ph(m.group(0)), text)
|
||||||
|
|
||||||
# 3) Convert markdown links [text](url) → <url|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(
|
text = re.sub(
|
||||||
r'\[([^\]]+)\]\(([^)]+)\)',
|
r'\[([^\]]+)\]\(([^()]*(?:\([^()]*\)[^()]*)*)\)',
|
||||||
lambda m: _ph(f'<{m.group(2)}|{m.group(1)}>'),
|
_convert_markdown_link,
|
||||||
text,
|
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):
|
def _convert_header(m):
|
||||||
inner = m.group(1).strip()
|
inner = m.group(1).strip()
|
||||||
# Strip redundant bold markers inside a header
|
# Strip redundant bold markers inside a header
|
||||||
@@ -474,34 +496,39 @@ class SlackAdapter(BasePlatformAdapter):
|
|||||||
r'^#{1,6}\s+(.+)$', _convert_header, text, flags=re.MULTILINE
|
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(
|
text = re.sub(
|
||||||
r'\*\*(.+?)\*\*',
|
r'\*\*(.+?)\*\*',
|
||||||
lambda m: _ph(f'*{m.group(1)}*'),
|
lambda m: _ph(f'*{m.group(1)}*'),
|
||||||
text,
|
text,
|
||||||
)
|
)
|
||||||
|
|
||||||
# 6) Convert italic: _text_ stays as _text_ (already Slack italic)
|
# 10) Convert italic: _text_ stays as _text_ (already Slack italic)
|
||||||
# Single *text* → _text_ (Slack italic)
|
# Single *text* → _text_ (Slack italic)
|
||||||
text = re.sub(
|
text = re.sub(
|
||||||
r'(?<!\*)\*([^*\n]+)\*(?!\*)',
|
r'(?<!\*)\*([^*\n]+)\*(?!\*)',
|
||||||
lambda m: _ph(f'_{m.group(1)}_'),
|
lambda m: _ph(f'_{m.group(1)}_'),
|
||||||
text,
|
text,
|
||||||
)
|
)
|
||||||
|
|
||||||
# 7) Convert strikethrough: ~~text~~ → ~text~
|
# 11) Convert strikethrough: ~~text~~ → ~text~
|
||||||
text = re.sub(
|
text = re.sub(
|
||||||
r'~~(.+?)~~',
|
r'~~(.+?)~~',
|
||||||
lambda m: _ph(f'~{m.group(1)}~'),
|
lambda m: _ph(f'~{m.group(1)}~'),
|
||||||
text,
|
text,
|
||||||
)
|
)
|
||||||
|
|
||||||
# 8) Convert blockquotes: > text → > text (same syntax, just ensure
|
# 12) Blockquotes: > prefix is already protected by step 5 above.
|
||||||
# no extra escaping happens to the > character)
|
|
||||||
# Slack uses the same > prefix, so this is a no-op for content.
|
|
||||||
|
|
||||||
# 9) Restore placeholders in reverse order
|
# 13) Restore placeholders in reverse order
|
||||||
for key in reversed(list(placeholders.keys())):
|
for key in reversed(placeholders):
|
||||||
text = text.replace(key, placeholders[key])
|
text = text.replace(key, placeholders[key])
|
||||||
|
|
||||||
return text
|
return text
|
||||||
|
|||||||
@@ -619,6 +619,18 @@ class TestFormatMessage:
|
|||||||
result = adapter.format_message("[click here](https://example.com)")
|
result = adapter.format_message("[click here](https://example.com)")
|
||||||
assert result == "<https://example.com|click here>"
|
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):
|
def test_strikethrough(self, adapter):
|
||||||
assert adapter.format_message("~~deleted~~") == "~deleted~"
|
assert adapter.format_message("~~deleted~~") == "~deleted~"
|
||||||
|
|
||||||
@@ -643,6 +655,325 @@ class TestFormatMessage:
|
|||||||
def test_none_passthrough(self, adapter):
|
def test_none_passthrough(self, adapter):
|
||||||
assert adapter.format_message(None) is None
|
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
|
# TestReactions
|
||||||
@@ -1085,6 +1416,48 @@ class TestMessageSplitting:
|
|||||||
await adapter.send("C123", "hello world")
|
await adapter.send("C123", "hello world")
|
||||||
assert adapter._app.client.chat_postMessage.call_count == 1
|
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
|
# TestReplyBroadcast
|
||||||
|
|||||||
@@ -32,6 +32,30 @@ def _install_telegram_mock(monkeypatch, bot):
|
|||||||
monkeypatch.setitem(sys.modules, "telegram.constants", constants_mod)
|
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:
|
class TestSendMessageTool:
|
||||||
def test_cron_duplicate_target_is_skipped_and_explained(self):
|
def test_cron_duplicate_target_is_skipped_and_explained(self):
|
||||||
home = SimpleNamespace(chat_id="-1001")
|
home = SimpleNamespace(chat_id="-1001")
|
||||||
@@ -426,7 +450,7 @@ class TestSendToPlatformChunking:
|
|||||||
result = asyncio.run(
|
result = asyncio.run(
|
||||||
_send_to_platform(
|
_send_to_platform(
|
||||||
Platform.DISCORD,
|
Platform.DISCORD,
|
||||||
SimpleNamespace(enabled=True, token="tok", extra={}),
|
SimpleNamespace(enabled=True, token="***", extra={}),
|
||||||
"ch", long_msg,
|
"ch", long_msg,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@@ -435,8 +459,115 @@ class TestSendToPlatformChunking:
|
|||||||
for call in send.await_args_list:
|
for call in send.await_args_list:
|
||||||
assert len(call.args[2]) <= 2020 # each chunk fits the limit
|
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):
|
def test_telegram_media_attaches_to_last_chunk(self):
|
||||||
"""When chunked, media files are sent only with the last chunk."""
|
|
||||||
sent_calls = []
|
sent_calls = []
|
||||||
|
|
||||||
async def fake_send(token, chat_id, message, media_files=None, thread_id=None):
|
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 []
|
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)
|
# Platform message length limits (from adapter class attributes)
|
||||||
_MAX_LENGTHS = {
|
_MAX_LENGTHS = {
|
||||||
Platform.TELEGRAM: TelegramAdapter.MAX_MESSAGE_LENGTH,
|
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"
|
url = "https://slack.com/api/chat.postMessage"
|
||||||
headers = {"Authorization": f"Bearer {token}", "Content-Type": "application/json"}
|
headers = {"Authorization": f"Bearer {token}", "Content-Type": "application/json"}
|
||||||
async with aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=30)) as session:
|
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()
|
data = await resp.json()
|
||||||
if data.get("ok"):
|
if data.get("ok"):
|
||||||
return {"success": True, "platform": "slack", "chat_id": chat_id, "message_id": data.get("ts")}
|
return {"success": True, "platform": "slack", "chat_id": chat_id, "message_id": data.get("ts")}
|
||||||
|
|||||||
Reference in New Issue
Block a user