Skip to content

Commit 5a2055c

Browse files
committed
fix: split long qqofficial messages by 4000-char limit
1 parent 609e576 commit 5a2055c

3 files changed

Lines changed: 290 additions & 3 deletions

File tree

astrbot/core/platform/sources/qqofficial/qqofficial_message_event.py

Lines changed: 61 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
import logging
55
import os
66
import random
7+
import re
78
from typing import cast
89

910
import aiofiles
@@ -211,6 +212,59 @@ def _extract_response_message_id(ret) -> str | None:
211212
ret_id = getattr(ret, "id", None)
212213
return str(ret_id) if ret_id is not None else None
213214

215+
# QQ 官方单条消息文本长度上限,超出会被平台截断(错误码 40054007)
216+
QQ_MAX_LENGTH = 4000
217+
218+
_SPLIT_PATTERNS = {
219+
"paragraph": re.compile(r"\n\n"),
220+
"line": re.compile(r"\n"),
221+
"sentence": re.compile(r"[.!?。!?]"),
222+
"word": re.compile(r"\s"),
223+
}
224+
225+
@classmethod
226+
def _split_message(cls, text: str) -> list[str]:
227+
"""按段落 → 行 → 句子 → 词回退切分超长文本,对齐 Telegram 的 _split_message。"""
228+
if len(text) <= cls.QQ_MAX_LENGTH:
229+
return [text]
230+
231+
chunks = []
232+
remaining = text
233+
while remaining:
234+
if len(remaining) <= cls.QQ_MAX_LENGTH:
235+
chunks.append(remaining)
236+
break
237+
238+
split_point = cls.QQ_MAX_LENGTH
239+
segment = remaining[: cls.QQ_MAX_LENGTH]
240+
for _, pattern in cls._SPLIT_PATTERNS.items():
241+
if matches := list(pattern.finditer(segment)):
242+
split_point = matches[-1].end()
243+
break
244+
245+
chunks.append(remaining[:split_point])
246+
remaining = remaining[split_point:].lstrip()
247+
248+
return chunks
249+
250+
@classmethod
251+
def _split_message_chain_by_length(
252+
cls, message_chains: list[MessageChain]
253+
) -> list[MessageChain]:
254+
"""按 QQ 单条消息长度限制拆分消息链,防止长文本被平台截断。"""
255+
result: list[MessageChain] = []
256+
for chain in message_chains:
257+
text = "".join(c.text for c in chain.chain if isinstance(c, Plain))
258+
if len(text) <= cls.QQ_MAX_LENGTH:
259+
result.append(chain)
260+
continue
261+
parts = cls._split_message(text)
262+
non_plain = [c for c in chain.chain if not isinstance(c, Plain)]
263+
# 首个分片保留非 Plain 组件(如图片),保证含媒体消息的超长文本也被拆分
264+
result.append(chain.derive([*non_plain, Plain(parts[0])]))
265+
result.extend(chain.derive([Plain(part)]) for part in parts[1:])
266+
return result
267+
214268
@staticmethod
215269
def _split_message_chain_by_media(message: MessageChain) -> list[MessageChain]:
216270
chunks: list[MessageChain] = []
@@ -249,11 +303,15 @@ async def _post_send(self, stream: dict | None = None):
249303
return None
250304

251305
message_chains = self._split_message_chain_by_media(self.send_buffer)
252-
stream_for_chain = stream if len(message_chains) == 1 else None
306+
message_chains = self._split_message_chain_by_length(message_chains)
253307

254308
ret = None
255-
for message_chain in message_chains:
256-
ret = await self._post_send_one(message_chain, stream_for_chain)
309+
# C2C 流式一次调用是同一消息的连续分片;buffer 被拆成多段时,
310+
# 只有最后一段携带 stream 载荷(保证流会话 id 连续、最终 state=10 正常结束),
311+
# 其余段降级为非流式发送。
312+
for index, message_chain in enumerate(message_chains):
313+
chain_stream = stream if index == len(message_chains) - 1 else None
314+
ret = await self._post_send_one(message_chain, chain_stream)
257315

258316
self.send_buffer = None
259317

astrbot/core/platform/sources/qqofficial/qqofficial_platform_adapter.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -336,6 +336,9 @@ async def _send_by_session_common(
336336
message_chains = QQOfficialMessageEvent._split_message_chain_by_media(
337337
message_chain
338338
)
339+
message_chains = QQOfficialMessageEvent._split_message_chain_by_length(
340+
message_chains
341+
)
339342
if len(message_chains) > 1:
340343
for split_message_chain in message_chains:
341344
await self._send_by_session_common(session, split_message_chain)
Lines changed: 226 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,226 @@
1+
"""Tests for QQ Official single-message length splitting.
2+
3+
QQ 官方 API 对单条消息文本有长度上限(约 4000 字符,超限返回错误码
4+
40054007)。适配器在发送前按该限制切分,防止长文本被平台截断。
5+
"""
6+
7+
from __future__ import annotations
8+
9+
import asyncio
10+
from types import SimpleNamespace
11+
from unittest.mock import AsyncMock
12+
13+
import botpy.message
14+
import pytest
15+
16+
from astrbot.api.event import MessageChain
17+
from astrbot.api.message_components import Image, Plain
18+
from astrbot.api.platform import (
19+
AstrBotMessage,
20+
MessageMember,
21+
MessageType,
22+
PlatformMetadata,
23+
)
24+
from astrbot.core.platform.message_session import MessageSession
25+
from astrbot.core.platform.sources.qqofficial.qqofficial_message_event import (
26+
QQOfficialMessageEvent,
27+
)
28+
from astrbot.core.platform.sources.qqofficial.qqofficial_platform_adapter import (
29+
QQOfficialPlatformAdapter,
30+
)
31+
32+
33+
def _extract_send_text(kwargs: dict) -> str:
34+
text = kwargs.get("content")
35+
if text:
36+
return str(text)
37+
md = kwargs.get("markdown")
38+
if isinstance(md, dict):
39+
return str(md.get("content") or "")
40+
if md is not None:
41+
return str(getattr(md, "content", None) or "")
42+
return ""
43+
44+
45+
def _make_group_event() -> QQOfficialMessageEvent:
46+
raw = botpy.message.GroupMessage(
47+
api=None,
48+
event_id="event-1",
49+
data={
50+
"id": "msg-1",
51+
"author": {"member_openid": "member-1"},
52+
"group_openid": "group-1",
53+
"content": "ping",
54+
"timestamp": "0",
55+
},
56+
)
57+
abm = AstrBotMessage()
58+
abm.message_id = "msg-1"
59+
abm.session_id = "group-1"
60+
abm.group_id = "group-1"
61+
abm.self_id = "bot-1"
62+
abm.sender = MessageMember(user_id="member-1", nickname="u")
63+
abm.type = MessageType.GROUP_MESSAGE
64+
abm.message_str = "ping"
65+
abm.message = []
66+
abm.raw_message = raw
67+
meta = PlatformMetadata(name="qq_official", description="t", id="qq_official")
68+
bot = SimpleNamespace(api=SimpleNamespace(post_group_message=AsyncMock()))
69+
return QQOfficialMessageEvent(
70+
message_str="ping",
71+
message_obj=abm,
72+
platform_meta=meta,
73+
session_id="group-1",
74+
bot=bot, # type: ignore[arg-type]
75+
)
76+
77+
78+
def _make_c2c_event() -> QQOfficialMessageEvent:
79+
raw = botpy.message.C2CMessage(
80+
api=None,
81+
event_id="event-2",
82+
data={
83+
"id": "msg-c2c",
84+
"author": {"user_openid": "user-1"},
85+
"content": "ping",
86+
"timestamp": "0",
87+
},
88+
)
89+
abm = AstrBotMessage()
90+
abm.message_id = "msg-c2c"
91+
abm.session_id = "user-1"
92+
abm.self_id = "bot-1"
93+
abm.sender = MessageMember(user_id="user-1", nickname="u")
94+
abm.type = MessageType.FRIEND_MESSAGE
95+
abm.message_str = "ping"
96+
abm.message = []
97+
abm.raw_message = raw
98+
meta = PlatformMetadata(name="qq_official", description="t", id="qq_official")
99+
bot = SimpleNamespace(api=SimpleNamespace(post_group_message=AsyncMock()))
100+
return QQOfficialMessageEvent(
101+
message_str="ping",
102+
message_obj=abm,
103+
platform_meta=meta,
104+
session_id="user-1",
105+
bot=bot, # type: ignore[arg-type]
106+
)
107+
108+
109+
def test_split_message_respects_limit() -> None:
110+
long_text = "不稀罕。" * 1500
111+
chunks = QQOfficialMessageEvent._split_message(long_text)
112+
assert len(chunks) > 1
113+
assert all(len(c) <= QQOfficialMessageEvent.QQ_MAX_LENGTH for c in chunks)
114+
assert "".join(chunks) == long_text
115+
116+
117+
def test_split_message_short_unchanged() -> None:
118+
text = "短消息"
119+
assert QQOfficialMessageEvent._split_message(text) == [text]
120+
121+
122+
def test_split_message_chain_by_length_keeps_short_media_chain() -> None:
123+
chain = MessageChain(chain=[Plain("标题"), Image(file="x.png")])
124+
assert QQOfficialMessageEvent._split_message_chain_by_length([chain]) == [chain]
125+
126+
127+
def test_split_message_chain_by_length_splits_media_caption() -> None:
128+
caption = "标题" + "长" * 5000
129+
chain = MessageChain(chain=[Plain(caption), Image(file="x.png")])
130+
result = QQOfficialMessageEvent._split_message_chain_by_length([chain])
131+
assert len(result) > 1
132+
# 媒体保留在首个分片,其余分片为纯文本
133+
assert isinstance(result[0].chain[0], Image)
134+
assert all(not isinstance(c, Image) for c in result[0].chain[1:]) and all(
135+
not any(isinstance(c, Image) for c in ch.chain) for ch in result[1:]
136+
)
137+
texts = ["".join(c.text for c in ch.chain if isinstance(c, Plain)) for ch in result]
138+
assert all(len(t) <= QQOfficialMessageEvent.QQ_MAX_LENGTH for t in texts)
139+
assert "".join(texts) == caption
140+
141+
142+
def test_split_message_chain_by_length_splits_long_text() -> None:
143+
chain = MessageChain(chain=[Plain("不稀罕。" * 1500)])
144+
result = QQOfficialMessageEvent._split_message_chain_by_length([chain])
145+
assert len(result) > 1
146+
assert all(
147+
len(c.chain[0].text) <= QQOfficialMessageEvent.QQ_MAX_LENGTH for c in result
148+
)
149+
assert "".join(c.chain[0].text for c in result) == chain.chain[0].text
150+
151+
152+
@pytest.mark.asyncio
153+
async def test_post_send_splits_long_reply_into_multiple_messages() -> None:
154+
event = _make_group_event()
155+
captured: list[str] = []
156+
157+
async def capture(**kwargs):
158+
captured.append(_extract_send_text(kwargs))
159+
return {"id": f"out-{len(captured)}"}
160+
161+
event.bot.api.post_group_message = AsyncMock(side_effect=capture)
162+
163+
long_text = "不稀罕。" * 1500
164+
await event.send(MessageChain(chain=[Plain(long_text)]))
165+
166+
assert len(captured) > 1
167+
assert all(len(t) <= QQOfficialMessageEvent.QQ_MAX_LENGTH for t in captured)
168+
assert "".join(captured) == long_text
169+
170+
171+
@pytest.mark.asyncio
172+
async def test_post_send_c2c_stream_split_streams_only_last_chunk() -> None:
173+
event = _make_c2c_event()
174+
event.send_buffer = MessageChain(chain=[Plain("不稀罕。" * 1500)])
175+
sent: list[dict | None] = []
176+
177+
async def fake_post_c2c_message(openid, **kwargs):
178+
sent.append(kwargs.get("stream"))
179+
return SimpleNamespace(id=f"c2c-{len(sent)}")
180+
181+
event.post_c2c_message = AsyncMock( # type: ignore[method-assign]
182+
side_effect=fake_post_c2c_message
183+
)
184+
185+
stream_payload = {"state": 1, "id": "prev-1", "index": 3, "reset": False}
186+
await event._post_send(stream=stream_payload)
187+
188+
# 一次流式 flush 超长被拆成多段时,只有最后一段携带 stream 载荷,
189+
# 保证 C2C 流会话 id 连续、最终 state=10 能正常结束;其余段非流式发送。
190+
assert len(sent) > 1
191+
assert all(s is None for s in sent[:-1])
192+
assert sent[-1] == stream_payload
193+
194+
195+
@pytest.mark.asyncio
196+
async def test_send_by_session_splits_long_proactive_text() -> None:
197+
adapter = QQOfficialPlatformAdapter(
198+
{
199+
"id": "qq-official-test",
200+
"appid": "123",
201+
"secret": "secret",
202+
"enable_group_c2c": True,
203+
"enable_guild_direct_message": False,
204+
},
205+
{},
206+
asyncio.Queue(),
207+
)
208+
adapter.client.api = SimpleNamespace(
209+
post_group_message=AsyncMock(return_value={"id": "sent-1"}),
210+
post_message=AsyncMock(),
211+
)
212+
adapter._session_scene["group-1"] = "group"
213+
214+
long_text = "不稀罕。" * 1500
215+
await adapter.send_by_session(
216+
MessageSession("qq_official", MessageType.GROUP_MESSAGE, "group-1"),
217+
MessageChain(chain=[Plain(long_text)]),
218+
)
219+
220+
assert adapter.client.api.post_group_message.await_count > 1
221+
sent = [
222+
_extract_send_text(kwargs)
223+
for _, kwargs in adapter.client.api.post_group_message.await_args_list
224+
]
225+
assert all(len(t) <= QQOfficialMessageEvent.QQ_MAX_LENGTH for t in sent)
226+
assert "".join(sent) == long_text

0 commit comments

Comments
 (0)