diff --git a/backend/app/channels/slack.py b/backend/app/channels/slack.py index 0f042510cd..9d562b7582 100644 --- a/backend/app/channels/slack.py +++ b/backend/app/channels/slack.py @@ -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 ``&``, ``<`` and + ``>`` so that raw ``<...>`` always marks a control sequence (mention, + link). ``&`` is decoded last so an escaped entity is decoded only once. + """ + return text.replace("<", "<").replace(">", ">").replace("&", "&") + + 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 <@...> and must stay text. + text = _unescape_slack_text(text) if not text: return diff --git a/backend/tests/test_channels.py b/backend/tests/test_channels.py index 6755cd10d5..f045a54e3c 100644 --- a/backend/tests/test_channels.py +++ b/backend/tests/test_channels.py @@ -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 &, < and >. + text = self._inbound_text_for( + { + "type": "app_mention", + "text": '<@UBOT> if a < b && c > d: print("R&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 "<", which Slack sends as "&lt;". + assert self._inbound_text_for({"text": "write &lt; in HTML"}) == "write < in HTML" + + def test_inbound_text_keeps_slack_control_sequences(self): + text = self._inbound_text_for({"text": "ask <@U999> about > now"}) + + assert text == "ask <@U999> about > now" + + def test_inbound_text_decodes_entities_inside_slack_link_labels(self): + text = self._inbound_text_for({"text": "see "}) + + assert text == "see " + def test_app_mention_strips_labelled_leading_bot_mention(self): from app.channels.slack import SlackChannel