diff --git a/plugins/platforms/slack/adapter.py b/plugins/platforms/slack/adapter.py index a13c8d2c54b..67b30864812 100644 --- a/plugins/platforms/slack/adapter.py +++ b/plugins/platforms/slack/adapter.py @@ -3256,7 +3256,14 @@ class SlackAdapter(BasePlatformAdapter): # 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(">", ">") + # Single pass: sequential str.replace would re-scan its own output, so + # the & from "&" could pair with a following "lt;" and decode twice + # ("&lt;" → "<" → "<"), destroying literal entity text. + text = re.sub( + r"&(amp|lt|gt);", + lambda m: {"amp": "&", "lt": "<", "gt": ">"}[m.group(1)], + text, + ) text = text.replace("&", "&").replace("<", "<").replace(">", ">") # 7) Convert headers (## Title) → *Title* (bold) diff --git a/tests/gateway/test_slack.py b/tests/gateway/test_slack.py index edcfa79102a..f601cf63e25 100644 --- a/tests/gateway/test_slack.py +++ b/tests/gateway/test_slack.py @@ -4068,6 +4068,16 @@ class TestFormatMessage: """Already-escaped > in plain text must not become &gt;.""" assert adapter.format_message("5 > 3") == "5 > 3" + def test_escaped_entity_text_not_double_decoded(self, adapter): + """&lt; is the wire form of the literal text < — it must survive. + + The unescape pass must not re-scan its own output: decoding & to & + first must not let the resulting & combine with a following lt; into a + second decode, or the literal text is silently destroyed. + """ + assert adapter.format_message("&lt;") == "&lt;" + assert adapter.format_message("&gt;") == "&gt;" + def test_mixed_raw_and_escaped_entities(self, adapter): """Raw & and pre-escaped & coexist correctly.""" result = adapter.format_message("AT&T and & entity")