diff --git a/tests/tools/test_send_message_tool.py b/tests/tools/test_send_message_tool.py index 3a6ad11fdea18..c697721c286a1 100644 --- a/tests/tools/test_send_message_tool.py +++ b/tests/tools/test_send_message_tool.py @@ -1545,6 +1545,82 @@ def test_room_id_is_percent_encoded_in_url(self): assert "!HLOQwxYGgFPMPJUSNR:matrix.org" not in put_url +class TestSendMatrixFormattedBody: + """_send_matrix renders markdown to formatted_body in the cron delivery + path, with a regex fallback when the optional ``markdown`` library is + not installed (default Docker image case — issue #32486).""" + + @staticmethod + def _build_session_mock(): + mock_resp = MagicMock() + mock_resp.status = 200 + mock_resp.json = AsyncMock(return_value={"event_id": "$evt"}) + mock_resp.__aenter__ = AsyncMock(return_value=mock_resp) + mock_resp.__aexit__ = AsyncMock(return_value=None) + + mock_session = MagicMock() + mock_session.put = MagicMock(return_value=mock_resp) + mock_session.__aenter__ = AsyncMock(return_value=mock_session) + mock_session.__aexit__ = AsyncMock(return_value=None) + return mock_session + + def test_markdown_lib_path_sets_formatted_body(self): + """When ``markdown`` is installed, the rendered HTML lands in + ``formatted_body`` and ``format`` is set to the Matrix custom-HTML + content type.""" + pytest.importorskip("markdown") + from tools.send_message_tool import _send_matrix + + mock_session = self._build_session_mock() + with patch("aiohttp.ClientSession", return_value=mock_session): + result = asyncio.run( + _send_matrix( + "tok", + {"homeserver": "https://matrix.example.org"}, + "!room:matrix.org", + "## Heading\n\n- bullet\n- bullet", + ) + ) + + assert result["success"] is True + payload = mock_session.put.call_args.kwargs["json"] + assert payload["format"] == "org.matrix.custom.html" + # Element X compatibility: headings collapse to , not . + assert "Heading" in payload["formatted_body"] + assert "

" not in payload["formatted_body"] + # List rendering survives. + assert "
  • bullet
  • " in payload["formatted_body"] + + def test_fallback_path_sets_formatted_body_without_markdown_lib(self): + """When ``markdown`` is missing (default Docker image), the regex + fallback shared with ``MatrixAdapter`` still produces HTML — the + client no longer sees raw ``##`` / ``-`` / ``|...|`` source.""" + from tools.send_message_tool import _send_matrix + + mock_session = self._build_session_mock() + # Hide the markdown library to force the ImportError branch. + with patch.dict(sys.modules, {"markdown": None}): + with patch("aiohttp.ClientSession", return_value=mock_session): + result = asyncio.run( + _send_matrix( + "tok", + {"homeserver": "https://matrix.example.org"}, + "!room:matrix.org", + "## Heading\n\n- bullet\n- bullet", + ) + ) + + assert result["success"] is True + payload = mock_session.put.call_args.kwargs["json"] + assert payload["format"] == "org.matrix.custom.html" + # Element X compatibility carries through the fallback too. + assert "Heading" in payload["formatted_body"] + assert "

    " not in payload["formatted_body"] + # Raw markdown source must NOT survive into formatted_body. + assert "## Heading" not in payload["formatted_body"] + assert "
  • bullet
  • " in payload["formatted_body"] + + # --------------------------------------------------------------------------- # Tests for _derive_forum_thread_name # --------------------------------------------------------------------------- diff --git a/tools/send_message_tool.py b/tools/send_message_tool.py index 9ea0b9af41b54..3eae215c16bfe 100644 --- a/tools/send_message_tool.py +++ b/tools/send_message_tool.py @@ -1375,17 +1375,24 @@ async def _send_matrix(token, extra, chat_id, message): url = f"{homeserver}/_matrix/client/v3/rooms/{encoded_room}/send/m.room.message/{txn_id}" headers = {"Authorization": f"Bearer {token}", "Content-Type": "application/json"} - # Build message payload with optional HTML formatted_body. + # Build message payload with HTML formatted_body. Prefer the + # ``markdown`` library (ships with the ``[matrix]`` extra); fall + # back to the regex converter shared with the gateway adapter so + # cron deliveries still get formatted_body when the lib isn't + # installed. The official Docker image installs ``[all]`` + + # ``[messaging]`` only — neither pulls Markdown, so this branch + # is the default path inside the container. payload = {"msgtype": "m.text", "body": message} try: import markdown as _md html = _md.markdown(message, extensions=["fenced_code", "tables"]) - # Convert h1-h6 to bold for Element X compatibility. - html = re.sub(r"(.*?)", r"\1", html) - payload["format"] = "org.matrix.custom.html" - payload["formatted_body"] = html except ImportError: - pass + from gateway.platforms.matrix import MatrixAdapter + html = MatrixAdapter._markdown_to_html_fallback(message) + # Convert h1-h6 to bold for Element X compatibility. + html = re.sub(r"(.*?)", r"\1", html) + payload["format"] = "org.matrix.custom.html" + payload["formatted_body"] = html async with aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=30)) as session: async with session.put(url, headers=headers, json=payload) as resp: