Skip to content
Open
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
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@

from astrbot.api import logger
from astrbot.api.event import AstrMessageEvent, MessageChain
from astrbot.api.message_components import At, File, Image, Plain, Record, Video
from astrbot.api.message_components import At, File, Image, Plain, Record, Reply, Video
from astrbot.api.platform import AstrBotMessage, Group, PlatformMetadata
from astrbot.core.platform.sources.qqofficial.qqofficial_chunked_upload import (
QQOFFICIAL_CHUNKED_UPLOAD_THRESHOLD,
Expand Down Expand Up @@ -405,6 +405,7 @@ async def _post_send_one(
video_file_source,
file_source,
file_name,
reply_message_id,
) = await QQOfficialMessageEvent._parse_to_qqofficial(message_to_send)

# C2C 流式仅用于文本分片,富媒体时降级为普通发送,避免平台侧流式校验报错。
Expand Down Expand Up @@ -453,6 +454,11 @@ async def _post_send_one(
if not isinstance(source, botpy.message.Message | botpy.message.DirectMessage):
payload["msg_seq"] = random.randint(1, 10000)

# 插件通过 Reply 组件给出的引用 id,四个发送接口(群/C2C/频道/频道私信)
# 都以 message_reference 接受;拿不到 id 时保持原样,不影响正文发送
if reply_message_id:
payload["message_reference"] = {"message_id": reply_message_id}

ret = None

match source:
Expand Down Expand Up @@ -634,13 +640,30 @@ async def _send_with_markdown_fallback(
if payload.get("msg_id"):
fallback_payload = payload.copy()
fallback_payload.pop("msg_id", None)
# 转主动发送时先保留引用:失败若与引用无关(如 markdown 校验),
# 引用上下文仍然有效,不应一并丢弃
try:
ret = await send_func(fallback_payload)
logger.info("[QQOfficial] 使用主动发送接口发送成功。")
return ret
except _QQOFFICIAL_SEND_API_ERRORS as fallback_err:
err = fallback_err
payload = fallback_payload
if not fallback_payload.get("message_reference"):
err = fallback_err
payload = fallback_payload
else:
# 主动发送仍失败,引用 id 可能已失效;去掉引用最后重试一次,
# 失效引用不应阻断正文送达
retry_payload = fallback_payload.copy()
retry_payload.pop("message_reference", None)
try:
ret = await send_func(retry_payload)
logger.info(
"[QQOfficial] 引用已失效,去掉引用后主动发送成功。"
)
return ret
except _QQOFFICIAL_SEND_API_ERRORS as retry_err:
err = retry_err
payload = retry_payload

if not isinstance(err, botpy.errors.ServerError):
raise
Expand Down Expand Up @@ -916,6 +939,7 @@ async def _parse_to_qqofficial(message: MessageChain):
video_file_source = None
file_source = None
file_name = None
reply_message_id = None
for i in message.chain:
if isinstance(i, Plain):
plain_text += i.text
Expand Down Expand Up @@ -967,6 +991,11 @@ async def _parse_to_qqofficial(message: MessageChain):
file_source = file_path
elif i.url:
file_source = i.url
elif isinstance(i, Reply) and not reply_message_id:
# 出站引用回复:取第一个带被引用消息 id 的 Reply 组件
quoted_id = str(i.id).strip()
if quoted_id:
reply_message_id = quoted_id
else:
logger.debug(f"qq_official 忽略 {i.type}")
return (
Expand All @@ -977,4 +1006,5 @@ async def _parse_to_qqofficial(message: MessageChain):
video_file_source,
file_source,
file_name,
reply_message_id,
)
Original file line number Diff line number Diff line change
Expand Up @@ -369,6 +369,7 @@ async def _send_by_session_common(
video_file_source,
file_source,
file_name,
reply_message_id,
) = await QQOfficialMessageEvent._parse_to_qqofficial(message_chain)
if (
not plain_text
Expand Down Expand Up @@ -409,6 +410,8 @@ async def _send_by_session_common(
}
if msg_id and not allow_group_proactive_send:
payload["msg_id"] = msg_id
if reply_message_id:
payload["message_reference"] = {"message_id": reply_message_id}
ret: Any = None
send_helper = SimpleNamespace(bot=self.client)

Expand Down
177 changes: 176 additions & 1 deletion tests/test_qqofficial_group_message_create.py
Original file line number Diff line number Diff line change
Expand Up @@ -251,6 +251,47 @@ async def test_parse_to_qqofficial_ignores_empty_at_component(qq: str | None):
assert parsed[0] == "hello"


@pytest.mark.asyncio
async def test_parse_to_qqofficial_extracts_reply_reference():
parsed = await QQOfficialMessageEvent._parse_to_qqofficial(
MessageChain(chain=[Reply(id="quoted-1"), Plain("hello")])
Comment thread
sourcery-ai[bot] marked this conversation as resolved.
)

assert parsed[7] == "quoted-1"


@pytest.mark.asyncio
async def test_parse_to_qqofficial_extracts_reply_reference_after_text():
parsed = await QQOfficialMessageEvent._parse_to_qqofficial(
MessageChain(chain=[Plain("hello"), Reply(id="quoted-1")])
)

assert parsed[7] == "quoted-1"


@pytest.mark.parametrize(
"chain",
[
MessageChain(chain=[Plain("hello")]),
MessageChain(chain=[Reply(id=""), Plain("hello")]),
],
)
@pytest.mark.asyncio
async def test_parse_to_qqofficial_reply_reference_defaults_to_none(chain):
parsed = await QQOfficialMessageEvent._parse_to_qqofficial(chain)

assert parsed[7] is None


@pytest.mark.asyncio
async def test_parse_to_qqofficial_keeps_first_reply_reference():
parsed = await QQOfficialMessageEvent._parse_to_qqofficial(
MessageChain(chain=[Reply(id="quoted-1"), Reply(id="quoted-2")])
)

assert parsed[7] == "quoted-1"


@pytest.mark.asyncio
async def test_legacy_group_at_path_forces_bot_mention_when_mentions_missing():
message = botpy.message.GroupMessage(
Expand Down Expand Up @@ -757,7 +798,7 @@ async def test_ws_group_send_by_session_with_media_uses_msg_type_7(monkeypatch):
adapter._session_scene["group-1"] = "group"

async def fake_parse(message_chain):
return ("caption", "fake-base64", None, None, None, None, None)
return ("caption", "fake-base64", None, None, None, None, None, None)

async def fake_upload_image(self_, image_base64, file_type, **kwargs):
return {"file_uuid": "u-1", "file_info": "i-1", "ttl": 0}
Expand All @@ -779,6 +820,140 @@ async def fake_upload_image(self_, image_base64, file_type, **kwargs):
assert kwargs["media"]["file_uuid"] == "u-1"


@pytest.mark.asyncio
async def test_ws_group_send_by_session_attaches_message_reference():
adapter = QQOfficialPlatformAdapter(
{
"id": "qq-official-test",
"appid": "123",
"secret": "secret",
"enable_group_c2c": True,
"enable_guild_direct_message": False,
},
{},
asyncio.Queue(),
)
adapter.client.api = SimpleNamespace(
post_group_message=AsyncMock(return_value={"id": "sent-quote"}),
post_message=AsyncMock(),
)
adapter._session_scene["group-1"] = "group"

await adapter.send_by_session(
MessageSession("qq_official", MessageType.GROUP_MESSAGE, "group-1"),
MessageChain(chain=[Reply(id="quoted-1"), Plain("reply text")]),
)

kwargs = adapter.client.api.post_group_message.await_args.kwargs
assert kwargs["message_reference"] == {"message_id": "quoted-1"}


@pytest.mark.asyncio
async def test_friend_send_by_session_attaches_message_reference():
adapter = QQOfficialPlatformAdapter(
{
"id": "qq-official-test",
"appid": "123",
"secret": "secret",
"enable_group_c2c": True,
"enable_guild_direct_message": False,
},
{},
asyncio.Queue(),
)
request = AsyncMock(return_value={"id": "sent-c2c-quote"})
adapter.client.api = SimpleNamespace(_http=SimpleNamespace(request=request))

await adapter.send_by_session(
MessageSession("qq_official", MessageType.FRIEND_MESSAGE, "user-1"),
MessageChain(chain=[Reply(id="quoted-2"), Plain("reply text")]),
)

request.assert_awaited_once()
json_payload = request.await_args.kwargs["json"]
assert json_payload["message_reference"] == {"message_id": "quoted-2"}


@pytest.mark.asyncio
async def test_send_by_session_without_reply_adds_no_message_reference():
adapter = QQOfficialPlatformAdapter(
{
"id": "qq-official-test",
"appid": "123",
"secret": "secret",
"enable_group_c2c": True,
"enable_guild_direct_message": False,
},
{},
asyncio.Queue(),
)
adapter.client.api = SimpleNamespace(
post_group_message=AsyncMock(return_value={"id": "sent-plain"}),
post_message=AsyncMock(),
)
adapter._session_scene["group-1"] = "group"

await adapter.send_by_session(
MessageSession("qq_official", MessageType.GROUP_MESSAGE, "group-1"),
MessageChain(chain=[Plain("plain text")]),
)

kwargs = adapter.client.api.post_group_message.await_args.kwargs
assert "message_reference" not in kwargs


@pytest.mark.asyncio
async def test_active_send_fallback_keeps_valid_message_reference():
calls = []

async def fake_send(payload):
calls.append(dict(payload))
if len(calls) == 1:
raise botpy.errors.ServerError("passive msg_id expired")
return {"id": "sent-active"}

payload = {
"content": "reply text",
"msg_type": 0,
"msg_id": "passive-1",
"message_reference": {"message_id": "quoted-1"},
}
ret = await QQOfficialMessageEvent._send_with_markdown_fallback(
fake_send, payload, "reply text"
)

assert ret == {"id": "sent-active"}
assert len(calls) == 2
assert "msg_id" not in calls[1]
assert calls[1]["message_reference"] == {"message_id": "quoted-1"}


@pytest.mark.asyncio
async def test_active_send_fallback_strips_reference_only_when_retry_fails():
calls = []

async def fake_send(payload):
calls.append(dict(payload))
if len(calls) < 3:
raise botpy.errors.ServerError("reference rejected")
return {"id": "sent-no-ref"}

payload = {
"content": "reply text",
"msg_type": 0,
"msg_id": "passive-1",
"message_reference": {"message_id": "quoted-1"},
}
ret = await QQOfficialMessageEvent._send_with_markdown_fallback(
fake_send, payload, "reply text"
)

assert ret == {"id": "sent-no-ref"}
assert len(calls) == 3
assert calls[1]["message_reference"] == {"message_id": "quoted-1"}
assert "message_reference" not in calls[2]


@pytest.mark.asyncio
async def test_friend_send_by_session_renders_markdown():
adapter = QQOfficialPlatformAdapter(
Expand Down
Loading