Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
150 changes: 150 additions & 0 deletions tests/tools/test_send_message_qqbot_markdown.py
Original file line number Diff line number Diff line change
@@ -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
20 changes: 16 additions & 4 deletions tools/send_message_tool.py
Original file line number Diff line number Diff line change
Expand Up @@ -1710,27 +1710,39 @@ 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,
"message_id": data.get("id")}

# 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,
"message_id": data.get("id")}

# 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,
Expand Down
Loading