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
22 changes: 22 additions & 0 deletions astrbot/core/config/default.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@

VERSION = "4.26.0-beta.1"
DB_PATH = os.path.join(get_astrbot_data_path(), "data_v4.db")
FORWARD_NODE_MAX_LENGTH_DEFAULT = 1000
FORWARD_NODE_HARD_LIMIT_DEFAULT = 1200
PERSONAL_WECHAT_CONFIG_METADATA = {
"weixin_oc_base_url": {
"description": "Base URL",
Expand Down Expand Up @@ -62,6 +64,8 @@
},
"reply_prefix": "",
"forward_threshold": 1500,
"forward_node_max_length": FORWARD_NODE_MAX_LENGTH_DEFAULT,
"forward_node_hard_limit": FORWARD_NODE_HARD_LIMIT_DEFAULT,
"enable_id_white_list": True,
"id_whitelist": [],
"id_whitelist_log": True,
Expand Down Expand Up @@ -1075,6 +1079,14 @@
"type": "int",
"hint": "超过一定字数后,机器人会将消息折叠成 QQ 群聊的 “转发消息”,以防止刷屏。目前仅 QQ 平台适配器适用。",
},
"forward_node_max_length": {
"type": "int",
"hint": "合并转发内单个节点期望的文本长度,达到该长度后会优先在句号、换行等自然断点附近切开。",
},
"forward_node_hard_limit": {
"type": "int",
"hint": "合并转发内单个节点的文本硬上限,超过后一定会强制切开,用来避开 QQ/NapCat 对单条转发节点的隐藏限制。",
},
"enable_id_white_list": {
"type": "bool",
},
Expand Down Expand Up @@ -3887,6 +3899,16 @@
"description": "转发消息的字数阈值",
"type": "int",
},
"platform_settings.forward_node_max_length": {
"description": "单个转发节点目标长度",
"type": "int",
"hint": "合并转发内单个节点期望容纳的文本长度,达到该长度后会优先寻找句号、换行等自然断点,避免一句话被切得太碎。",
},
"platform_settings.forward_node_hard_limit": {
"description": "单个转发节点硬上限",
"type": "int",
"hint": "合并转发内单个节点的最大文本长度。超过后一定会强制切开,用来避开 QQ/NapCat 对单条转发节点的隐藏限制。",
},
"platform_settings.empty_mention_waiting": {
"description": "只 @ 机器人是否触发等待",
"type": "bool",
Expand Down
161 changes: 153 additions & 8 deletions astrbot/core/pipeline/result_decorate/stage.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,20 @@
from collections.abc import AsyncGenerator

from astrbot.core import file_token_service, html_renderer, logger
from astrbot.core.message.components import At, Image, Json, Node, Plain, Record, Reply
from astrbot.core.config.default import (
FORWARD_NODE_HARD_LIMIT_DEFAULT,
FORWARD_NODE_MAX_LENGTH_DEFAULT,
)
from astrbot.core.message.components import (
At,
Image,
Json,
Node,
Nodes,
Plain,
Record,
Reply,
)
from astrbot.core.message.message_event_result import ResultContentType
from astrbot.core.pipeline.content_safety_check.stage import ContentSafetyCheckStage
from astrbot.core.platform.astr_message_event import AstrMessageEvent
Expand Down Expand Up @@ -43,6 +56,34 @@ async def initialize(self, ctx: PipelineContext) -> None:
"forward_threshold"
]

# Long-reply auto-forward node splitting settings
try:
self.forward_node_max_length = int(
ctx.astrbot_config["platform_settings"].get(
"forward_node_max_length", FORWARD_NODE_MAX_LENGTH_DEFAULT
)
)
except (TypeError, ValueError):
self.forward_node_max_length = FORWARD_NODE_MAX_LENGTH_DEFAULT
try:
self.forward_node_hard_limit = int(
ctx.astrbot_config["platform_settings"].get(
"forward_node_hard_limit", FORWARD_NODE_HARD_LIMIT_DEFAULT
)
)
except (TypeError, ValueError):
self.forward_node_hard_limit = FORWARD_NODE_HARD_LIMIT_DEFAULT
if self.forward_node_max_length <= 0:
self.forward_node_max_length = FORWARD_NODE_MAX_LENGTH_DEFAULT
if self.forward_node_hard_limit <= 0:
self.forward_node_hard_limit = FORWARD_NODE_HARD_LIMIT_DEFAULT
if self.forward_node_max_length > self.forward_node_hard_limit:
logger.warning(
"forward_node_max_length is greater than forward_node_hard_limit; "
"falling back to hard limit as target length."
)
self.forward_node_max_length = self.forward_node_hard_limit

trigger_probability = ctx.astrbot_config["provider_tts_settings"].get(
"trigger_probability",
1,
Expand Down Expand Up @@ -87,6 +128,20 @@ async def initialize(self, ctx: PipelineContext) -> None:
"segmented_reply"
]["content_cleanup_rule"]

# Natural breakpoints for forward node splitting: reuse segmented_reply.split_words plus newline
_forward_split_words = list(self.split_words) if self.split_words else []
if "\n" not in _forward_split_words:
_forward_split_words.append("\n")
if _forward_split_words:
_escaped = sorted(
[re.escape(word) for word in _forward_split_words],
key=len,
reverse=True,
)
self.forward_split_pattern = re.compile(f"(?:{'|'.join(_escaped)})+")
else:
self.forward_split_pattern = None

# exception
self.content_safe_check_reply = ctx.astrbot_config["content_safety"][
"also_use_in_response"
Expand Down Expand Up @@ -123,6 +178,89 @@ def _split_text_by_words(self, text: str) -> list[str]:
result.append(seg)
return result if result else [text]

@staticmethod
def _find_forward_split_pos(
text: str,
target_len: int,
hard_limit: int,
split_pattern: re.Pattern | None,
) -> int:
"""Find a split position for forward node plain text.

Prefer natural breakpoints between target_len and hard_limit.
If none exists, fall back to the nearest breakpoint before target_len.
If still none, hard-cut at hard_limit.
"""
search_end = min(hard_limit, len(text))
if len(text) <= target_len:
return len(text)

if split_pattern is not None:
previous_end = 0
for match in split_pattern.finditer(text, 0, search_end):
if match.end() >= target_len:
return match.end()
if match.end() > 0:
previous_end = match.end()
if previous_end > 0:
return previous_end

if len(text) > hard_limit:
return hard_limit
return search_end
Comment thread
Sisyphbaous-DT-Project marked this conversation as resolved.

def _build_forward_nodes(
self,
chain: list,
uin: str,
name: str,
) -> Nodes:
"""Split a message chain into multiple forward nodes.

Non-Plain components are kept in the node where they appear.
Each node's total plain text length will not exceed forward_node_hard_limit.
"""
nodes = Nodes([])
current_content: list = []
current_text_len = 0
target_len = self.forward_node_max_length
hard_limit = self.forward_node_hard_limit

def flush_current():
nonlocal current_content, current_text_len
if current_content:
nodes.nodes.append(Node(uin=uin, name=name, content=current_content))
current_content = []
current_text_len = 0

for comp in chain:
if isinstance(comp, Plain):
rest = comp.text or ""
while rest:
if current_text_len >= target_len:
flush_current()

remaining_target = max(1, target_len - current_text_len)
remaining_hard = max(1, hard_limit - current_text_len)
split_pos = self._find_forward_split_pos(
rest,
remaining_target,
remaining_hard,
self.forward_split_pattern,
)
split_pos = max(1, min(split_pos, remaining_hard, len(rest)))
current_content.append(Plain(rest[:split_pos]))
current_text_len += split_pos
rest = rest[split_pos:]

if rest:
flush_current()
else:
current_content.append(comp)

flush_current()
return nodes

async def process(
self,
event: AstrMessageEvent,
Expand Down Expand Up @@ -394,14 +532,21 @@ async def process(
if isinstance(comp, Plain):
word_cnt += len(comp.text)
if word_cnt > self.forward_threshold:
node = Node(
uin=event.get_self_id(),
name="AstrBot",
content=[*result.chain],
)
result.chain = [node]
# Skip if the chain already contains forward nodes.
if not any(
isinstance(comp, (Node, Nodes)) for comp in result.chain
):
nodes = self._build_forward_nodes(
result.chain,
event.get_self_id(),
"AstrBot",
)
result.chain = [nodes]

# at 回复 / 引用回复仅适用于纯文本或图文消息
# at 回复 / 引用回复仅适用于纯文本或图文消息。
# After forward conversion result.chain is [Nodes], so mention/quote
# decorations are not applied to forwarded messages. This matches the
# pre-existing single-Node behavior and keeps pipeline order stable.
can_decorate = all(
isinstance(item, (Plain, Image)) for item in result.chain
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -776,6 +776,14 @@
"forward_threshold": {
"description": "Forward Message Word Count Threshold"
},
"forward_node_max_length": {
"description": "Target Length per Forward Node",
"hint": "The expected plain-text length for each forward node. When reached, AstrBot will prefer to split at natural breakpoints like periods or newlines to avoid breaking sentences."
},
"forward_node_hard_limit": {
"description": "Hard Limit per Forward Node",
"hint": "The maximum plain-text length allowed in a single forward node. AstrBot will force-split beyond this limit to avoid QQ/NapCat hidden limits on a single node."
},
"empty_mention_waiting": {
"description": "Trigger Waiting on Mention-only Messages"
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -777,6 +777,14 @@
"forward_threshold": {
"description": "Порог количества слов для пересылки"
},
"forward_node_max_length": {
"description": "Целевая длина одного узла пересылки",
"hint": "Ожидаемая длина текста в одном узле пересылки. При достижении этого значения AstrBot будет стараться разделить текст на естественных границах, таких как точка или перевод строки."
},
"forward_node_hard_limit": {
"description": "Жёсткий лимит одного узла пересылки",
"hint": "Максимально допустимая длина текста в одном узле пересылки. При превышении этого значения AstrBot обязательно разделит текст, чтобы избежать скрытых ограничений QQ/NapCat."
},
"empty_mention_waiting": {
"description": "Реагировать на пустое упоминание (@бота)"
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -778,6 +778,14 @@
"forward_threshold": {
"description": "转发消息的字数阈值"
},
"forward_node_max_length": {
"description": "单个转发节点目标长度",
"hint": "合并转发内单个节点期望容纳的文本长度,达到该长度后会优先寻找句号、换行等自然断点,避免一句话被切得太碎。"
},
"forward_node_hard_limit": {
"description": "单个转发节点硬上限",
"hint": "合并转发内单个节点的最大文本长度。超过后一定会强制切开,用来避开 QQ/NapCat 对单条转发节点的隐藏限制。"
},
"empty_mention_waiting": {
"description": "只 @ 机器人是否触发等待"
}
Expand Down
Loading
Loading