fix(message): Fix private message sending failure caused by extra fields in Reply component's toDict method (#8477)

- Reply.toDict() 继承 BaseMessageComponent.toDict() 会将所有非 None 默认字段序列化,导致 OneBot V11 reply 段包含多余字段,引起私聊引用回复失败(message not found)
- 重写 Reply.toDict() 方法,仅返回 {"type": "reply", "data": {"id": str(self.id)}},符合协议标准
- 新增 tests/unit/test_aiocqhttp_reply.py,覆盖 Reply.toDict() 输出格式、_parse_onebot_json 路径及私聊发送场景的验证
This commit is contained in:
NayukiChiba
2026-06-02 08:40:55 +08:00
committed by GitHub
parent e8d13af5b9
commit 7d45a247d5
2 changed files with 203 additions and 0 deletions

View File

@@ -526,6 +526,10 @@ class Reply(BaseMessageComponent):
def __init__(self, **_) -> None:
super().__init__(**_)
def toDict(self):
"""仅输出 id 字段,符合 OneBot V11 reply 段标准格式。"""
return {"type": "reply", "data": {"id": str(self.id)}}
class Poke(BaseMessageComponent):
type: ComponentType = ComponentType.Poke

View File

@@ -0,0 +1,199 @@
"""测试 aiocqhttp 平台中私聊环境下引用回复Reply的消息发送行为。
Bug 背景在私聊中引用上文消息时OneBot 协议端返回
ActionFailed status='failed', retcode=100, wording='message not found'
根因Reply.toDict() 继承了 BaseMessageComponent.toDict()
会将所有非 None 的默认字段chain, sender_id, qq, seq 等)序列化到
OneBot 协议的 message 数组中。OneBot V11 标准只期望
{"type": "reply", "data": {"id": "..."}},多余的字段可能导致
协议端napcat/Lagrange查找消息时失败。
"""
from unittest.mock import AsyncMock
import pytest
import astrbot.core.message.components as Comp
from astrbot.core.message.message_event_result import MessageChain
from astrbot.core.pipeline.respond.stage import RespondStage # noqa: F401 — 预加载避免循环导入
from astrbot.core.platform.sources.aiocqhttp.aiocqhttp_message_event import (
AiocqhttpMessageEvent,
)
# ============================================================
# Reply.toDict() 输出格式测试
# ============================================================
def test_reply_to_dict_contains_only_id_in_data():
"""Reply.toDict() 应当只输出 id 字段,不含 chain、sender_id 等多余字段。
当前实际行为:继承了 BaseMessageComponent.toDict(),会将所有
非 None 的默认值chain: [], sender_id: 0, qq: 0, seq: 0 等)
一起序列化,违反了 OneBot V11 的 reply 段格式约定。
"""
reply = Comp.Reply(id="123456")
result = reply.toDict()
assert result["type"] == "reply"
assert "id" in result["data"]
# 这些字段不应出现在 OneBot 协议的 reply segment 中
unexpectedFields = ["chain", "sender_id", "qq", "seq", "text"]
for field in unexpectedFields:
if field in result["data"]:
pytest.fail(
f"Reply.toDict() 的 data 中不应包含 '{field}' 字段,"
f"但实际输出了 {field}={result['data'][field]!r}"
f"完整输出: {result}"
)
def test_reply_to_dict_outputs_only_id():
"""Reply.toDict() 应当只输出 id 字段,不含任何多余字段。"""
reply = Comp.Reply(id="123456")
result = reply.toDict()
assert result["type"] == "reply"
assert set(result["data"].keys()) == {"id"}, (
f"Reply.toDict() data 中包含多余字段: {set(result['data'].keys()) - {'id'}}"
)
assert result["data"]["id"] == "123456"
# ============================================================
# _parse_onebot_json 输出测试
# ============================================================
@pytest.mark.asyncio
async def test_parse_onebot_json_reply_produces_extra_fields():
"""_parse_onebot_json 处理 Reply 时会输出多余字段。
这验证了 bug 的链路:从 Reply 组件 → _parse_onebot_json →
OneBot 协议 payload多余字段一直传递到 send_private_msg。
"""
chain = MessageChain([Comp.Reply(id="123456"), Comp.Plain("你好")])
data = await AiocqhttpMessageEvent._parse_onebot_json(chain)
assert len(data) == 2
replySegment = data[0]
assert replySegment["type"] == "reply"
# 检查 reply 段的 data 中是否有多余字段
extraFields = [k for k in replySegment["data"] if k != "id"]
if extraFields:
pytest.fail(
f"_parse_onebot_json 输出的 reply 段包含了多余的 data 字段: "
f"{extraFields}。这些字段可能被 OneBot 协议端误解析,"
f"导致 message not found 错误。\n"
f"完整 reply 段: {replySegment}"
)
# ============================================================
# 私聊发送路径测试
# ============================================================
@pytest.mark.asyncio
async def test_send_private_msg_with_reply_includes_extra_fields():
"""验证私聊发送带 Reply 的消息时,实际传给 bot.send_private_msg 的
payload 包含多余字段。
这是导致私聊下 'message not found' 的直接原因:
OneBot 协议端收到的 reply 段数据不符合标准格式。
"""
bot = AsyncMock()
chain = MessageChain([Comp.Reply(id="123456"), Comp.Plain("你好")])
await AiocqhttpMessageEvent.send_message(
bot=bot,
message_chain=chain,
event=None,
is_group=False, # 私聊
session_id="987654",
)
# 验证调用了 send_private_msg而非 send_group_msg
bot.send_private_msg.assert_awaited_once()
callArgs = bot.send_private_msg.call_args
assert callArgs.kwargs["user_id"] == 987654
messages = callArgs.kwargs["message"]
assert len(messages) >= 1
replySegment = messages[0]
assert replySegment["type"] == "reply"
# 检查 payload 中的多余字段
extraFields = [k for k in replySegment["data"] if k != "id"]
if extraFields:
pytest.fail(
f"send_private_msg 的 message[0] reply 段包含了多余的 data 字段: "
f"{extraFields}\n"
f"这是导致私聊引用回复报 'message not found' 的根因。\n"
f"完整 payload: {messages}"
)
@pytest.mark.asyncio
async def test_send_group_msg_with_reply_also_includes_extra_fields():
"""对比:群聊发送带 Reply 的消息同样包含多余字段。
如果群聊引用回复正常而私聊失败,可能的原因是不同协议端
对多余字段的容忍度不同(例如 napcat 在 send_group_msg 中
忽略了多余字段,但在 send_private_msg 中严格校验)。
"""
bot = AsyncMock()
chain = MessageChain([Comp.Reply(id="123456"), Comp.Plain("你好")])
await AiocqhttpMessageEvent.send_message(
bot=bot,
message_chain=chain,
event=None,
is_group=True,
session_id="123456",
)
bot.send_group_msg.assert_awaited_once()
callArgs = bot.send_group_msg.call_args
messages = callArgs.kwargs["message"]
replySegment = messages[0]
assert replySegment["type"] == "reply"
extraFields = [k for k in replySegment["data"] if k != "id"]
if extraFields:
pytest.fail(
f"send_group_msg 的 reply 段也包含多余字段: {extraFields}\n"
f"完整 payload: {messages}"
)
# ============================================================
# Reply 组件只传 id 时的正确 OneBot 格式测试
# ============================================================
def test_reply_to_dict_matches_onebot_v11_format():
"""OneBot V11 标准 reply 段格式:
{"type": "reply", "data": {"id": "..."}}
"""
expected = {
"type": "reply",
"data": {"id": "123456"},
}
reply = Comp.Reply(id="123456")
actual = reply.toDict()
assert actual == expected, (
f"Reply.toDict() 输出不符合 OneBot V11 标准。\n"
f"期望: {expected}\n"
f"实际: {actual}"
)