fix(channels): decode Slack entity escapes in inbound message text (#6045)

* fix(channels): decode Slack entity escapes in inbound message text

Slack sends a user-typed &, < and > as &amp;, &lt; and &gt; in event text, so raw <...> always marks a control sequence. The Slack channel passed that text to the agent as-is, so code, comparisons and names like R&D reached the agent as 'a &lt; b', 'R&amp;D'. Decode the three entities after bot-mention stripping (amp last, so an escaped entity is decoded once) and keep mentions and links untouched.

* test(channels): cover Slack entities inside link labels

Signed-off-by: Willem Jiang <willem.jiang@gmail.com>

---------

Signed-off-by: Willem Jiang <willem.jiang@gmail.com>
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
This commit is contained in:
Bob 2026-09-30 09:38:05 +08:00 committed by GitHub
parent 81fa17c15a
commit 4394c23b66
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 56 additions and 0 deletions

View File

@ -79,6 +79,16 @@ def _strip_leading_slack_bot_mention(text: str, bot_user_id: str | None) -> str:
return text[end + 1 :].lstrip()
def _unescape_slack_text(text: str) -> str:
"""Decode the three entities Slack escapes in message text.
Slack sends a user-typed ``&``, ``<`` and ``>`` as ``&amp;``, ``&lt;`` and
``&gt;`` so that raw ``<...>`` always marks a control sequence (mention,
link). ``&amp;`` is decoded last so an escaped entity is decoded only once.
"""
return text.replace("&lt;", "<").replace("&gt;", ">").replace("&amp;", "&")
class SlackChannel(Channel):
"""Slack IM channel using Socket Mode (WebSocket, no public IP).
@ -349,6 +359,9 @@ class SlackChannel(Channel):
text = event.get("text", "").strip()
if event.get("type") == "app_mention":
text = _strip_leading_slack_bot_mention(text, self._bot_user_id)
# Decode after mention stripping: a real mention is a raw <@...>, while
# a user-typed "<@...>" arrives as &lt;@...&gt; and must stay text.
text = _unescape_slack_text(text)
if not text:
return

View File

@ -8855,6 +8855,49 @@ class TestSlackAllowedUsers:
assert inbound.text == "/help"
assert inbound.msg_type == InboundMessageType.COMMAND
def _inbound_text_for(self, event: dict) -> str:
from app.channels.slack import SlackChannel
bus = MessageBus()
bus.publish_inbound = AsyncMock()
channel = SlackChannel(bus=bus, config={"bot_user_id": "UBOT"})
channel._loop = self._immediate_loop()
channel._add_reaction = MagicMock()
channel._send_running_reply = MagicMock()
with patch(
"app.channels.slack.asyncio.run_coroutine_threadsafe",
side_effect=self._submit_coro,
):
channel._handle_message_event({"user": "U123456", "channel": "C123", "ts": "1710000000.000100", **event})
return bus.get_inbound_nowait().text
def test_inbound_text_decodes_slack_entity_escapes(self):
# Slack delivers a user-typed &, < and > as &amp;, &lt; and &gt;.
text = self._inbound_text_for(
{
"type": "app_mention",
"text": '<@UBOT> if a &lt; b &amp;&amp; c &gt; d: print("R&amp;D")',
}
)
assert text == 'if a < b && c > d: print("R&D")'
def test_inbound_text_decodes_an_escaped_entity_only_once(self):
# The user literally typed "&lt;", which Slack sends as "&amp;lt;".
assert self._inbound_text_for({"text": "write &amp;lt; in HTML"}) == "write &lt; in HTML"
def test_inbound_text_keeps_slack_control_sequences(self):
text = self._inbound_text_for({"text": "ask <@U999> about <https://example.com|the doc> &gt; now"})
assert text == "ask <@U999> about <https://example.com|the doc> > now"
def test_inbound_text_decodes_entities_inside_slack_link_labels(self):
text = self._inbound_text_for({"text": "see <https://x.com|a&amp;b>"})
assert text == "see <https://x.com|a&b>"
def test_app_mention_strips_labelled_leading_bot_mention(self):
from app.channels.slack import SlackChannel