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 @@ -4,6 +4,7 @@
import logging
import os
import random
import re
from typing import cast

import aiofiles
Expand Down Expand Up @@ -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:]

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] = []
Expand Down Expand Up @@ -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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
226 changes: 226 additions & 0 deletions tests/test_qqofficial_message_length_split.py
Original file line number Diff line number Diff line change
@@ -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
Loading