From 147788efaa71b09922be69fedd0821e894741285 Mon Sep 17 00:00:00 2001 From: briandevans <252620095+briandevans@users.noreply.github.com> Date: Fri, 15 May 2026 19:13:06 -0700 Subject: [PATCH] fix(send_message): honor markdown_support config for QQ platform (#26697) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `tools.send_message_tool._send_qqbot` hardcoded `msg_type: 0` (plain text) for every QQ endpoint, ignoring the `markdown_support` config in `pconfig.extra`. As a result, sending a markdown table to QQ via `send_message` arrived as raw markdown syntax even when the gateway adapter (which honors the same flag) would have rendered the table. Mirror the gateway adapter's pattern from `gateway/platforms/qqbot/adapter.py::_build_text_body`: when `markdown_support` is True (the default, matching the adapter), use a `{"markdown": {"content": ...}, "msg_type": 2}` body for the C2C (`/v2/users/...`) and group (`/v2/groups/...`) endpoints. Plain text (`msg_type: 0`) is still used when the flag is False. The guild channel endpoint (`/channels/...`) keeps the simpler `{"content": ...}` shape that the adapter's `_send_guild_text` already uses — guild channels don't take `msg_type` via this path. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../tools/test_send_message_qqbot_markdown.py | 150 ++++++++++++++++++ tools/send_message_tool.py | 20 ++- 2 files changed, 166 insertions(+), 4 deletions(-) create mode 100644 tests/tools/test_send_message_qqbot_markdown.py diff --git a/tests/tools/test_send_message_qqbot_markdown.py b/tests/tools/test_send_message_qqbot_markdown.py new file mode 100644 index 000000000000..58128fce195e --- /dev/null +++ b/tests/tools/test_send_message_qqbot_markdown.py @@ -0,0 +1,150 @@ +"""Regression test for #26697 — ``send_message`` honors ``markdown_support`` +for the QQ platform. + +The QQ gateway adapter (``gateway/platforms/qqbot/adapter.py``) reads +``markdown_support`` from ``pconfig.extra`` (default ``True``) and sends +C2C/group messages with ``msg_type: 2`` and a ``markdown.content`` body. +Before this fix the ``send_message`` tool's ``_send_qqbot`` helper +hardcoded ``msg_type: 0`` and a plain ``content`` field, so markdown +tables / formatted output were delivered as raw text. +""" + +import asyncio +from unittest.mock import AsyncMock, MagicMock, patch + +from gateway.config import PlatformConfig +from tools.send_message_tool import _send_qqbot + + +def _make_resp(status_code, json_data): + resp = MagicMock() + resp.status_code = status_code + resp.json = MagicMock(return_value=json_data or {}) + return resp + + +def _httpx_client_with_responses(responses): + """Return a ``patch`` context for ``httpx.AsyncClient`` that replies + with the supplied responses in order. + """ + client = AsyncMock() + client.post = AsyncMock(side_effect=responses) + client_ctx = MagicMock() + client_ctx.__aenter__ = AsyncMock(return_value=client) + client_ctx.__aexit__ = AsyncMock(return_value=False) + return client_ctx, client + + +def _make_pconfig(markdown_support=True): + extra = {"app_id": "1234567"} + if markdown_support is not None: + extra["markdown_support"] = markdown_support + return PlatformConfig(enabled=True, token="secret", extra=extra) + + +def _call(pconfig, content="| col1 | col2 |\n|---|---|\n| a | b |"): + return asyncio.run(_send_qqbot(pconfig, "openid-1", content)) + + +def test_c2c_uses_markdown_payload_when_markdown_support_true(): + token_resp = _make_resp(200, {"access_token": "tok"}) + channel_resp = _make_resp(404, {"code": 11403, "message": "频道不存在"}) + c2c_resp = _make_resp(200, {"id": "msg-1"}) + + client_ctx, client = _httpx_client_with_responses( + [token_resp, channel_resp, c2c_resp] + ) + with patch("httpx.AsyncClient", return_value=client_ctx): + result = _call(_make_pconfig(markdown_support=True)) + + assert result == { + "success": True, "platform": "qqbot", "chat_id": "openid-1", + "message_id": "msg-1", + } + c2c_call = client.post.await_args_list[2] + assert c2c_call.args[0] == "https://api.sgroup.qq.com/v2/users/openid-1/messages" + sent = c2c_call.kwargs["json"] + assert sent["msg_type"] == 2 + assert sent["markdown"]["content"].startswith("| col1 | col2 |") + assert "content" not in sent # the top-level `content` field is NOT used for markdown + + +def test_group_uses_markdown_payload_when_markdown_support_true(): + token_resp = _make_resp(200, {"access_token": "tok"}) + channel_resp = _make_resp(404, {}) + c2c_resp = _make_resp(404, {}) + group_resp = _make_resp(200, {"id": "msg-2"}) + + client_ctx, client = _httpx_client_with_responses( + [token_resp, channel_resp, c2c_resp, group_resp] + ) + with patch("httpx.AsyncClient", return_value=client_ctx): + result = _call(_make_pconfig(markdown_support=True)) + + assert result["success"] is True + group_call = client.post.await_args_list[3] + assert group_call.args[0] == "https://api.sgroup.qq.com/v2/groups/openid-1/messages" + sent = group_call.kwargs["json"] + assert sent["msg_type"] == 2 + assert sent["markdown"]["content"].startswith("| col1 | col2 |") + + +def test_c2c_uses_plain_text_when_markdown_support_false(): + token_resp = _make_resp(200, {"access_token": "tok"}) + channel_resp = _make_resp(404, {}) + c2c_resp = _make_resp(200, {"id": "msg-3"}) + + client_ctx, client = _httpx_client_with_responses( + [token_resp, channel_resp, c2c_resp] + ) + with patch("httpx.AsyncClient", return_value=client_ctx): + result = _call(_make_pconfig(markdown_support=False), content="hello") + + assert result["success"] is True + c2c_call = client.post.await_args_list[2] + sent = c2c_call.kwargs["json"] + assert sent["msg_type"] == 0 + assert sent["content"] == "hello" + assert "markdown" not in sent + + +def test_markdown_support_defaults_to_true_when_unset(): + """Default behavior must match the gateway adapter (also defaults True).""" + token_resp = _make_resp(200, {"access_token": "tok"}) + channel_resp = _make_resp(404, {}) + c2c_resp = _make_resp(200, {"id": "msg-4"}) + + client_ctx, client = _httpx_client_with_responses( + [token_resp, channel_resp, c2c_resp] + ) + # markdown_support is intentionally absent from extra + pconfig = PlatformConfig(enabled=True, token="secret", extra={"app_id": "111"}) + with patch("httpx.AsyncClient", return_value=client_ctx): + result = asyncio.run(_send_qqbot(pconfig, "openid-1", "hi")) + + assert result["success"] is True + sent = client.post.await_args_list[2].kwargs["json"] + assert sent["msg_type"] == 2 + assert sent["markdown"]["content"] == "hi" + + +def test_channel_endpoint_payload_unchanged(): + """The guild channel endpoint never used markdown; ensure the fix does + not start sending an unsupported payload shape to it. + """ + token_resp = _make_resp(200, {"access_token": "tok"}) + channel_resp = _make_resp(200, {"id": "msg-5"}) + + client_ctx, client = _httpx_client_with_responses( + [token_resp, channel_resp] + ) + with patch("httpx.AsyncClient", return_value=client_ctx): + result = _call(_make_pconfig(markdown_support=True)) + + assert result["success"] is True + channel_call = client.post.await_args_list[1] + assert channel_call.args[0] == "https://api.sgroup.qq.com/channels/openid-1/messages" + sent = channel_call.kwargs["json"] + assert "content" in sent + assert "markdown" not in sent + assert "msg_type" not in sent diff --git a/tools/send_message_tool.py b/tools/send_message_tool.py index 9ea0b9af41b5..ad74ae3a056f 100644 --- a/tools/send_message_tool.py +++ b/tools/send_message_tool.py @@ -1710,11 +1710,23 @@ async def _send_qqbot(pconfig, chat_id, message): "Authorization": f"QQBot {access_token}", "Content-Type": "application/json", } - payload = {"content": message[:4000], "msg_type": 0} + # Mirror the gateway adapter: when `markdown_support: true` is + # configured (the default), C2C and group endpoints expect a + # `markdown.content` body with `msg_type: 2`. Plain text uses + # `content` with `msg_type: 0`. The guild channel endpoint uses + # the simpler `{"content": ...}` shape regardless, matching + # `gateway/platforms/qqbot/adapter.py::_send_guild_text`. + markdown_support = bool(extra.get("markdown_support", True)) + content = message[:4000] + if markdown_support: + v2_payload = {"markdown": {"content": content}, "msg_type": 2} + else: + v2_payload = {"content": content, "msg_type": 0} + channel_payload = {"content": content} # Try channel endpoint first (works for guild channels) url = f"https://api.sgroup.qq.com/channels/{chat_id}/messages" - resp = await client.post(url, json=payload, headers=headers) + resp = await client.post(url, json=channel_payload, headers=headers) if resp.status_code in {200, 201}: data = resp.json() return {"success": True, "platform": "qqbot", "chat_id": chat_id, @@ -1722,7 +1734,7 @@ async def _send_qqbot(pconfig, chat_id, message): # If channel endpoint failed (likely "频道不存在"), try C2C endpoint url_c2c = f"https://api.sgroup.qq.com/v2/users/{chat_id}/messages" - resp_c2c = await client.post(url_c2c, json=payload, headers=headers) + resp_c2c = await client.post(url_c2c, json=v2_payload, headers=headers) if resp_c2c.status_code in {200, 201}: data = resp_c2c.json() return {"success": True, "platform": "qqbot", "chat_id": chat_id, @@ -1730,7 +1742,7 @@ async def _send_qqbot(pconfig, chat_id, message): # If C2C also failed, try group endpoint url_group = f"https://api.sgroup.qq.com/v2/groups/{chat_id}/messages" - resp_group = await client.post(url_group, json=payload, headers=headers) + resp_group = await client.post(url_group, json=v2_payload, headers=headers) if resp_group.status_code in {200, 201}: data = resp_group.json() return {"success": True, "platform": "qqbot", "chat_id": chat_id,