mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-07-25 07:28:07 +00:00
fix(channels): escape Slack reserved chars before mrkdwn conversion (#4197)
* fix(channels): escape &/</> in Slack outbound text before mrkdwn conversion Slack requires callers to replace &, <, and > with their HTML entity equivalents before sending message text, since an unescaped <...> triggers Slack's own mention/link syntax (e.g. <@USERID>, <http://url|label>). SlackChannel.send() ran msg.text through the markdown-to-mrkdwn converter and sent the result straight to chat_postMessage with no escaping step, so technical/code content like "if a < b && b > c:" would arrive as literal Slack markup and render broken or misinterpreted. Escaping must happen before the mrkdwn conversion, not after: the converter emits its own <url|label> syntax for real markdown links ([text](url)), and that generated syntax must reach Slack unescaped. Escaping raw input first (via html.escape(text, quote=False), which replaces & before </>) and leaving the converter's output alone satisfies both requirements. * fix(channels): preserve a line-leading > as Slack's blockquote marker _escape_slack_text's html.escape(text, quote=False) replaces every ">" with ">", including a ">" at the start of a line -- Slack's own mrkdwn blockquote marker, which the converter otherwise passes through unchanged. Agent output using markdown blockquotes for quoting/callouts lost its blockquote styling and arrived as a literal "> " prefix instead of a rendered blockquote. Only "&" and "<" neutralize Slack's "<...>" mention/link syntax; ">" is special to Slack only at the start of a line. Restore a line-leading ">" to a literal character after escaping (re.sub with MULTILINE), so the mrkdwn converter still renders it as a blockquote; a "<"/"&" anywhere, and a ">" that is not at a line start, still escape. New tests cover the line-start preservation, that the exemption does not widen into "never escape '>'" (a mid-line/non-leading ">" still escapes), and that restoration applies per line in multiline text; all three revert cleanly against the unconditional html.escape() to reproduce the blockquote-corruption bug. Full test_channels.py (259 tests) plus ruff check/format are clean.
This commit is contained in:
parent
ca16b64b26
commit
8153e68eb8
@ -3,7 +3,9 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import html
|
||||
import logging
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
from markdown_to_mrkdwn import SlackMarkdownConverter
|
||||
@ -18,6 +20,35 @@ logger = logging.getLogger(__name__)
|
||||
_slack_md_converter = SlackMarkdownConverter()
|
||||
|
||||
|
||||
def _escape_slack_text(text: str) -> str:
|
||||
"""Escape Slack's reserved characters (``&``, ``<``, ``>``) in raw message text,
|
||||
except a ``>`` at the very start of a line -- Slack's own blockquote marker.
|
||||
|
||||
Slack requires callers to replace these with their HTML entity equivalents
|
||||
(``&``, ``<``, ``>``) before sending message text -- an unescaped
|
||||
``<...>`` triggers Slack's own mention/link syntax (e.g. ``<@USERID>``,
|
||||
``<http://url|label>``). See:
|
||||
https://api.slack.com/reference/surfaces/formatting#escaping
|
||||
|
||||
This MUST run before ``_slack_md_converter.convert()``, not after: the
|
||||
converter emits its own mrkdwn link syntax (``<url|label>``) for real
|
||||
markdown links, and that generated syntax must reach Slack unescaped.
|
||||
Escaping the raw input first -- and leaving the converter's own output
|
||||
alone -- satisfies both requirements. ``html.escape(..., quote=False)``
|
||||
replaces ``&`` before ``<``/``>``, so the entities it introduces are never
|
||||
re-escaped.
|
||||
|
||||
Only ``&`` and ``<`` neutralize Slack's ``<...>`` mention/link syntax; a
|
||||
``>`` is special to Slack only at the start of a line, where the mrkdwn
|
||||
converter passes it through unchanged as a blockquote marker. Escaping
|
||||
every ``>`` would turn a quoted line into visible ``>`` text instead of
|
||||
a rendered blockquote, so a line-leading ``>`` is restored to a literal
|
||||
``>`` after escaping; a ``>`` anywhere else in the text still escapes.
|
||||
"""
|
||||
escaped = html.escape(text, quote=False)
|
||||
return re.sub(r"(?m)^>", ">", escaped)
|
||||
|
||||
|
||||
def _normalize_allowed_users(allowed_users: Any) -> set[str]:
|
||||
if allowed_users is None:
|
||||
return set()
|
||||
@ -128,7 +159,7 @@ class SlackChannel(Channel):
|
||||
|
||||
kwargs: dict[str, Any] = {
|
||||
"channel": msg.chat_id,
|
||||
"text": _slack_md_converter.convert(msg.text),
|
||||
"text": _slack_md_converter.convert(_escape_slack_text(msg.text)),
|
||||
}
|
||||
if msg.thread_ts:
|
||||
kwargs["thread_ts"] = msg.thread_ts
|
||||
|
||||
@ -7153,6 +7153,110 @@ class TestSlackMarkdownConversion:
|
||||
assert "*Title*" in result
|
||||
assert "#" not in result
|
||||
|
||||
def test_converter_passes_reserved_characters_through_unchanged(self):
|
||||
# The library itself never escapes Slack's reserved characters -- this
|
||||
# pins that assumption so SlackChannel.send() knows it must do so itself.
|
||||
from app.channels.slack import _slack_md_converter
|
||||
|
||||
result = _slack_md_converter.convert("if a < b && b > c:")
|
||||
assert result == "if a < b && b > c:"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Slack outbound text escaping tests (Slack's &/</> HTML-entity requirement)
|
||||
#
|
||||
# Slack requires callers to replace &, <, and > with their HTML entity
|
||||
# equivalents before sending message text, because an unescaped `<...>`
|
||||
# triggers Slack's own mention/link syntax (e.g. `<@USERID>`,
|
||||
# `<http://url|label>`). See:
|
||||
# https://api.slack.com/reference/surfaces/formatting#escaping
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestSlackTextEscaping:
|
||||
@staticmethod
|
||||
def _sent_text(text: str) -> str:
|
||||
"""Send *text* through SlackChannel.send() and return the resulting
|
||||
Slack API ``text`` kwarg, without actually hitting the network."""
|
||||
from app.channels.slack import SlackChannel
|
||||
|
||||
captured: dict[str, object] = {}
|
||||
|
||||
async def go():
|
||||
bus = MessageBus()
|
||||
ch = SlackChannel(bus=bus, config={"bot_token": "xoxb-test", "app_token": "xapp-test"})
|
||||
|
||||
mock_web = MagicMock()
|
||||
|
||||
def post_message(**kwargs):
|
||||
captured.update(kwargs)
|
||||
return MagicMock()
|
||||
|
||||
mock_web.chat_postMessage = post_message
|
||||
ch._web_client = mock_web
|
||||
|
||||
msg = OutboundMessage(channel_name="slack", chat_id="C123", thread_id="t1", text=text)
|
||||
await ch.send(msg)
|
||||
|
||||
_run(go())
|
||||
return captured["text"]
|
||||
|
||||
def test_raw_angle_brackets_and_ampersand_are_escaped(self):
|
||||
# Realistic technical/code content containing all three reserved
|
||||
# characters must arrive escaped, so Slack renders it as literal text
|
||||
# instead of attempting to parse a broken mention/link.
|
||||
sent = self._sent_text("if a < b && b > c:")
|
||||
assert sent == "if a < b && b > c:"
|
||||
assert "<" not in sent
|
||||
assert ">" not in sent
|
||||
|
||||
def test_bot_mention_syntax_is_neutralized_not_interpreted(self):
|
||||
# Raw text that happens to look like a mention must not survive as
|
||||
# live `<@...>` syntax -- Slack would otherwise try to resolve it.
|
||||
sent = self._sent_text("please ask <@U12345> for review")
|
||||
assert sent == "please ask <@U12345> for review"
|
||||
|
||||
def test_real_markdown_link_still_converts_without_double_escaping(self):
|
||||
# Critical non-regression case: escaping must run BEFORE mrkdwn
|
||||
# conversion, not after. The converter's own generated `<url|label>`
|
||||
# syntax for a real markdown link must survive untouched -- if
|
||||
# escaping ran after conversion instead, this would corrupt into
|
||||
# `<url|label>` and Slack would render a dead link.
|
||||
sent = self._sent_text("See [DeerFlow docs](https://example.com/docs) for more.")
|
||||
assert "<https://example.com/docs|DeerFlow docs>" in sent
|
||||
assert "<" not in sent
|
||||
assert ">" not in sent
|
||||
|
||||
def test_ampersand_in_link_url_is_escaped_before_conversion(self):
|
||||
# & must be escaped first (before < and >) so it doesn't double-escape
|
||||
# the &/</> entities being introduced, and a literal '&' in a
|
||||
# URL must still come through as & per Slack's escaping rule --
|
||||
# even inside the converter's own generated <url|label> syntax.
|
||||
sent = self._sent_text("[Search](https://example.com?a=1&b=2)")
|
||||
assert "<https://example.com?a=1&b=2|Search>" in sent
|
||||
|
||||
def test_blockquote_marker_at_line_start_is_preserved(self):
|
||||
# A ">" at the very start of a line is Slack's own blockquote marker
|
||||
# (the mrkdwn converter passes it through unchanged), not part of the
|
||||
# <...> mention/link syntax that & and < neutralize. Escaping it would
|
||||
# turn a quoted line into visible ">" text instead of a rendered
|
||||
# blockquote.
|
||||
sent = self._sent_text("> quoted text")
|
||||
assert sent == "> quoted text"
|
||||
|
||||
def test_blockquote_marker_exemption_is_line_start_only(self):
|
||||
# The line-start exemption must not widen into "never escape '>'":
|
||||
# a "<"/"&" anywhere, and a ">" that is NOT at the start of a line,
|
||||
# still escape -- only the leading marker is restored.
|
||||
sent = self._sent_text("> a < b & c > d")
|
||||
assert sent == "> a < b & c > d"
|
||||
|
||||
def test_blockquote_marker_restored_on_every_line(self):
|
||||
# The restoration must apply per-line (re.MULTILINE), not just once
|
||||
# at the start of the whole string.
|
||||
sent = self._sent_text("intro\n> first quote\nmiddle\n> second quote")
|
||||
assert sent == "intro\n> first quote\nmiddle\n> second quote"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Telegram streaming tests
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user