From 5a2055c87c02462dc101927a57a39cd03bc4cb2e Mon Sep 17 00:00:00 2001 From: wcqqq1214 Date: Fri, 7 Aug 2026 22:11:49 +0800 Subject: [PATCH 1/2] fix: split long qqofficial messages by 4000-char limit --- .../qqofficial/qqofficial_message_event.py | 64 ++++- .../qqofficial/qqofficial_platform_adapter.py | 3 + tests/test_qqofficial_message_length_split.py | 226 ++++++++++++++++++ 3 files changed, 290 insertions(+), 3 deletions(-) create mode 100644 tests/test_qqofficial_message_length_split.py diff --git a/astrbot/core/platform/sources/qqofficial/qqofficial_message_event.py b/astrbot/core/platform/sources/qqofficial/qqofficial_message_event.py index 806034bb94..de4f7bfb2e 100644 --- a/astrbot/core/platform/sources/qqofficial/qqofficial_message_event.py +++ b/astrbot/core/platform/sources/qqofficial/qqofficial_message_event.py @@ -4,6 +4,7 @@ import logging import os import random +import re from typing import cast import aiofiles @@ -211,6 +212,59 @@ def _extract_response_message_id(ret) -> str | None: ret_id = getattr(ret, "id", None) return str(ret_id) if ret_id is not None else None + # QQ 官方单条消息文本长度上限,超出会被平台截断(错误码 40054007) + QQ_MAX_LENGTH = 4000 + + _SPLIT_PATTERNS = { + "paragraph": re.compile(r"\n\n"), + "line": re.compile(r"\n"), + "sentence": re.compile(r"[.!?。!?]"), + "word": re.compile(r"\s"), + } + + @classmethod + def _split_message(cls, text: str) -> list[str]: + """按段落 → 行 → 句子 → 词回退切分超长文本,对齐 Telegram 的 _split_message。""" + if len(text) <= cls.QQ_MAX_LENGTH: + return [text] + + chunks = [] + remaining = text + while remaining: + if len(remaining) <= cls.QQ_MAX_LENGTH: + chunks.append(remaining) + break + + split_point = cls.QQ_MAX_LENGTH + segment = remaining[: cls.QQ_MAX_LENGTH] + for _, pattern in cls._SPLIT_PATTERNS.items(): + if matches := list(pattern.finditer(segment)): + split_point = matches[-1].end() + break + + chunks.append(remaining[:split_point]) + remaining = remaining[split_point:].lstrip() + + return chunks + + @classmethod + def _split_message_chain_by_length( + cls, message_chains: list[MessageChain] + ) -> list[MessageChain]: + """按 QQ 单条消息长度限制拆分消息链,防止长文本被平台截断。""" + result: list[MessageChain] = [] + for chain in message_chains: + text = "".join(c.text for c in chain.chain if isinstance(c, Plain)) + if len(text) <= cls.QQ_MAX_LENGTH: + result.append(chain) + continue + parts = cls._split_message(text) + non_plain = [c for c in chain.chain if not isinstance(c, Plain)] + # 首个分片保留非 Plain 组件(如图片),保证含媒体消息的超长文本也被拆分 + result.append(chain.derive([*non_plain, Plain(parts[0])])) + result.extend(chain.derive([Plain(part)]) for part in parts[1:]) + return result + @staticmethod def _split_message_chain_by_media(message: MessageChain) -> list[MessageChain]: chunks: list[MessageChain] = [] @@ -249,11 +303,15 @@ async def _post_send(self, stream: dict | None = None): return None message_chains = self._split_message_chain_by_media(self.send_buffer) - stream_for_chain = stream if len(message_chains) == 1 else None + message_chains = self._split_message_chain_by_length(message_chains) ret = None - for message_chain in message_chains: - ret = await self._post_send_one(message_chain, stream_for_chain) + # C2C 流式一次调用是同一消息的连续分片;buffer 被拆成多段时, + # 只有最后一段携带 stream 载荷(保证流会话 id 连续、最终 state=10 正常结束), + # 其余段降级为非流式发送。 + for index, message_chain in enumerate(message_chains): + chain_stream = stream if index == len(message_chains) - 1 else None + ret = await self._post_send_one(message_chain, chain_stream) self.send_buffer = None diff --git a/astrbot/core/platform/sources/qqofficial/qqofficial_platform_adapter.py b/astrbot/core/platform/sources/qqofficial/qqofficial_platform_adapter.py index 66d2fc3472..4afcde1ac3 100644 --- a/astrbot/core/platform/sources/qqofficial/qqofficial_platform_adapter.py +++ b/astrbot/core/platform/sources/qqofficial/qqofficial_platform_adapter.py @@ -336,6 +336,9 @@ async def _send_by_session_common( message_chains = QQOfficialMessageEvent._split_message_chain_by_media( message_chain ) + message_chains = QQOfficialMessageEvent._split_message_chain_by_length( + message_chains + ) if len(message_chains) > 1: for split_message_chain in message_chains: await self._send_by_session_common(session, split_message_chain) diff --git a/tests/test_qqofficial_message_length_split.py b/tests/test_qqofficial_message_length_split.py new file mode 100644 index 0000000000..010947cbb8 --- /dev/null +++ b/tests/test_qqofficial_message_length_split.py @@ -0,0 +1,226 @@ +"""Tests for QQ Official single-message length splitting. + +QQ 官方 API 对单条消息文本有长度上限(约 4000 字符,超限返回错误码 +40054007)。适配器在发送前按该限制切分,防止长文本被平台截断。 +""" + +from __future__ import annotations + +import asyncio +from types import SimpleNamespace +from unittest.mock import AsyncMock + +import botpy.message +import pytest + +from astrbot.api.event import MessageChain +from astrbot.api.message_components import Image, Plain +from astrbot.api.platform import ( + AstrBotMessage, + MessageMember, + MessageType, + PlatformMetadata, +) +from astrbot.core.platform.message_session import MessageSession +from astrbot.core.platform.sources.qqofficial.qqofficial_message_event import ( + QQOfficialMessageEvent, +) +from astrbot.core.platform.sources.qqofficial.qqofficial_platform_adapter import ( + QQOfficialPlatformAdapter, +) + + +def _extract_send_text(kwargs: dict) -> str: + text = kwargs.get("content") + if text: + return str(text) + md = kwargs.get("markdown") + if isinstance(md, dict): + return str(md.get("content") or "") + if md is not None: + return str(getattr(md, "content", None) or "") + return "" + + +def _make_group_event() -> QQOfficialMessageEvent: + raw = botpy.message.GroupMessage( + api=None, + event_id="event-1", + data={ + "id": "msg-1", + "author": {"member_openid": "member-1"}, + "group_openid": "group-1", + "content": "ping", + "timestamp": "0", + }, + ) + abm = AstrBotMessage() + abm.message_id = "msg-1" + abm.session_id = "group-1" + abm.group_id = "group-1" + abm.self_id = "bot-1" + abm.sender = MessageMember(user_id="member-1", nickname="u") + abm.type = MessageType.GROUP_MESSAGE + abm.message_str = "ping" + abm.message = [] + abm.raw_message = raw + meta = PlatformMetadata(name="qq_official", description="t", id="qq_official") + bot = SimpleNamespace(api=SimpleNamespace(post_group_message=AsyncMock())) + return QQOfficialMessageEvent( + message_str="ping", + message_obj=abm, + platform_meta=meta, + session_id="group-1", + bot=bot, # type: ignore[arg-type] + ) + + +def _make_c2c_event() -> QQOfficialMessageEvent: + raw = botpy.message.C2CMessage( + api=None, + event_id="event-2", + data={ + "id": "msg-c2c", + "author": {"user_openid": "user-1"}, + "content": "ping", + "timestamp": "0", + }, + ) + abm = AstrBotMessage() + abm.message_id = "msg-c2c" + abm.session_id = "user-1" + abm.self_id = "bot-1" + abm.sender = MessageMember(user_id="user-1", nickname="u") + abm.type = MessageType.FRIEND_MESSAGE + abm.message_str = "ping" + abm.message = [] + abm.raw_message = raw + meta = PlatformMetadata(name="qq_official", description="t", id="qq_official") + bot = SimpleNamespace(api=SimpleNamespace(post_group_message=AsyncMock())) + return QQOfficialMessageEvent( + message_str="ping", + message_obj=abm, + platform_meta=meta, + session_id="user-1", + bot=bot, # type: ignore[arg-type] + ) + + +def test_split_message_respects_limit() -> None: + long_text = "不稀罕。" * 1500 + chunks = QQOfficialMessageEvent._split_message(long_text) + assert len(chunks) > 1 + assert all(len(c) <= QQOfficialMessageEvent.QQ_MAX_LENGTH for c in chunks) + assert "".join(chunks) == long_text + + +def test_split_message_short_unchanged() -> None: + text = "短消息" + assert QQOfficialMessageEvent._split_message(text) == [text] + + +def test_split_message_chain_by_length_keeps_short_media_chain() -> None: + chain = MessageChain(chain=[Plain("标题"), Image(file="x.png")]) + assert QQOfficialMessageEvent._split_message_chain_by_length([chain]) == [chain] + + +def test_split_message_chain_by_length_splits_media_caption() -> None: + caption = "标题" + "长" * 5000 + chain = MessageChain(chain=[Plain(caption), Image(file="x.png")]) + result = QQOfficialMessageEvent._split_message_chain_by_length([chain]) + assert len(result) > 1 + # 媒体保留在首个分片,其余分片为纯文本 + assert isinstance(result[0].chain[0], Image) + assert all(not isinstance(c, Image) for c in result[0].chain[1:]) and all( + not any(isinstance(c, Image) for c in ch.chain) for ch in result[1:] + ) + texts = ["".join(c.text for c in ch.chain if isinstance(c, Plain)) for ch in result] + assert all(len(t) <= QQOfficialMessageEvent.QQ_MAX_LENGTH for t in texts) + assert "".join(texts) == caption + + +def test_split_message_chain_by_length_splits_long_text() -> None: + chain = MessageChain(chain=[Plain("不稀罕。" * 1500)]) + result = QQOfficialMessageEvent._split_message_chain_by_length([chain]) + assert len(result) > 1 + assert all( + len(c.chain[0].text) <= QQOfficialMessageEvent.QQ_MAX_LENGTH for c in result + ) + assert "".join(c.chain[0].text for c in result) == chain.chain[0].text + + +@pytest.mark.asyncio +async def test_post_send_splits_long_reply_into_multiple_messages() -> None: + event = _make_group_event() + captured: list[str] = [] + + async def capture(**kwargs): + captured.append(_extract_send_text(kwargs)) + return {"id": f"out-{len(captured)}"} + + event.bot.api.post_group_message = AsyncMock(side_effect=capture) + + long_text = "不稀罕。" * 1500 + await event.send(MessageChain(chain=[Plain(long_text)])) + + assert len(captured) > 1 + assert all(len(t) <= QQOfficialMessageEvent.QQ_MAX_LENGTH for t in captured) + assert "".join(captured) == long_text + + +@pytest.mark.asyncio +async def test_post_send_c2c_stream_split_streams_only_last_chunk() -> None: + event = _make_c2c_event() + event.send_buffer = MessageChain(chain=[Plain("不稀罕。" * 1500)]) + sent: list[dict | None] = [] + + async def fake_post_c2c_message(openid, **kwargs): + sent.append(kwargs.get("stream")) + return SimpleNamespace(id=f"c2c-{len(sent)}") + + event.post_c2c_message = AsyncMock( # type: ignore[method-assign] + side_effect=fake_post_c2c_message + ) + + stream_payload = {"state": 1, "id": "prev-1", "index": 3, "reset": False} + await event._post_send(stream=stream_payload) + + # 一次流式 flush 超长被拆成多段时,只有最后一段携带 stream 载荷, + # 保证 C2C 流会话 id 连续、最终 state=10 能正常结束;其余段非流式发送。 + assert len(sent) > 1 + assert all(s is None for s in sent[:-1]) + assert sent[-1] == stream_payload + + +@pytest.mark.asyncio +async def test_send_by_session_splits_long_proactive_text() -> None: + 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-1"}), + post_message=AsyncMock(), + ) + adapter._session_scene["group-1"] = "group" + + long_text = "不稀罕。" * 1500 + await adapter.send_by_session( + MessageSession("qq_official", MessageType.GROUP_MESSAGE, "group-1"), + MessageChain(chain=[Plain(long_text)]), + ) + + assert adapter.client.api.post_group_message.await_count > 1 + sent = [ + _extract_send_text(kwargs) + for _, kwargs in adapter.client.api.post_group_message.await_args_list + ] + assert all(len(t) <= QQOfficialMessageEvent.QQ_MAX_LENGTH for t in sent) + assert "".join(sent) == long_text From 282673233e5829c12db404945aa41aed26e2b77d Mon Sep 17 00:00:00 2001 From: wcqqq1214 Date: Fri, 7 Aug 2026 23:00:36 +0800 Subject: [PATCH 2/2] fix: preserve whitespace when splitting long qqofficial messages --- .../platform/sources/qqofficial/qqofficial_message_event.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/astrbot/core/platform/sources/qqofficial/qqofficial_message_event.py b/astrbot/core/platform/sources/qqofficial/qqofficial_message_event.py index de4f7bfb2e..8495dd34af 100644 --- a/astrbot/core/platform/sources/qqofficial/qqofficial_message_event.py +++ b/astrbot/core/platform/sources/qqofficial/qqofficial_message_event.py @@ -243,7 +243,7 @@ def _split_message(cls, text: str) -> list[str]: break chunks.append(remaining[:split_point]) - remaining = remaining[split_point:].lstrip() + remaining = remaining[split_point:] return chunks