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
5 changes: 4 additions & 1 deletion astrbot/core/pipeline/result_decorate/stage.py
Original file line number Diff line number Diff line change
Expand Up @@ -414,7 +414,10 @@ async def process(
At(qq=event.get_sender_id(), name=event.get_sender_name()),
)
if len(result.chain) > 1 and isinstance(result.chain[1], Plain):
result.chain[1].text = "\n" + result.chain[1].text
# At 与正文之间用一个空格分隔,不强制换行
# 各平台适配器(如 aiocqhttp)会在发送时进一步处理前导空白,
# 确保最终渲染为 "@用户 你好" 而非 "@用户\n你好"
result.chain[1] = Plain(" " + result.chain[1].text)

# 引用回复
if self.reply_with_quote:
Expand Down
45 changes: 42 additions & 3 deletions astrbot/core/platform/sources/aiocqhttp/aiocqhttp_message_event.py
Original file line number Diff line number Diff line change
Expand Up @@ -68,22 +68,61 @@ async def _from_segment_to_dict(segment: BaseMessageComponent) -> dict:

@staticmethod
async def _parse_onebot_json(message_chain: MessageChain):
Comment thread
sourcery-ai[bot] marked this conversation as resolved.
"""解析成 OneBot json 格式"""
"""解析成 OneBot json 格式

将消息链转换为 OneBot 协议的消息段数组.
特别处理 At 组件与后续内容的间距:
- At + Plain 文本:确保一个空格分隔,避免粘连或双空格
- At + 非 Plain(图片/文件等):插入空格文本段分隔
- At 在链末尾:不添加多余空格
- 纯空白 Plain(如仅含换行/空格):跳过,不重置 At 标志位
"""
ret = []

# 标记前一个段是否为 At 组件,用于决定是否需要在当前段前插入空格
prev_is_at = False

for segment in message_chain.chain:
if isinstance(segment, At):
# At 组件后插入一个空格,避免与后续文本粘连
# At 组件:记录到结果,并设置标志位
# 空格由后续段决定如何插入(避免无条件插入导致末尾多余空格)
d = await AiocqhttpMessageEvent._from_segment_to_dict(segment)
ret.append(d)
ret.append({"type": "text", "data": {"text": " "}})
prev_is_at = True

elif isinstance(segment, Plain):
# 跳过纯空白文本(如单独的换行符、空格等)
# 注意:不重置 prev_is_at,避免 [At, Plain("\n"), Plain("你好")]
# 这种场景下空白 Plain 阻断空格插入
if not segment.text.strip():
continue

if prev_is_at:
# 前一个是 At:去除 Plain 的前导空白后,统一在前面加一个空格
# .lstrip() 的作用:
# result_decorate 阶段可能已在文本前加了空格或换行,
# 直接拼接会导致 "@用户 \n你好" 这样的双空白
# 统一用 " " 替换所有前导空白,确保 @ 与正文之间仅有一个空格
# 注意:不修改 segment.text,避免污染原始 MessageChain(影响 hook 等消费者)
text = " " + segment.text.lstrip()
prev_is_at = False
ret.append({"type": "text", "data": {"text": text}})
continue

d = await AiocqhttpMessageEvent._from_segment_to_dict(segment)
ret.append(d)
Comment on lines +100 to 113
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

直接修改 segment.text 会对传入的 message_chain 中的 Plain 对象产生副作用。如果该消息链在后续的其他平台适配器、插件钩子或日志记录中被复用,其内容将被非预期地篡改。

建议在调用 _from_segment_to_dict 转换后,直接修改生成的字典 d,从而避免对原始 segment 对象的就地修改(In-place mutation)。

Suggested change
if prev_is_at:
# 前一个是 At:去除 Plain 的前导空白后,统一在前面加一个空格
# .lstrip() 的作用:
# result_decorate 阶段可能已在文本前加了空格或换行,
# 直接拼接会导致 "@用户 \n你好" 这样的双空白
# 统一用 " " 替换所有前导空白,确保 @ 与正文之间仅有一个空格
segment.text = " " + segment.text.lstrip()
prev_is_at = False
d = await AiocqhttpMessageEvent._from_segment_to_dict(segment)
ret.append(d)
d = await AiocqhttpMessageEvent._from_segment_to_dict(segment)
if prev_is_at:
# 前一个是 At:去除 Plain 的前导空白后,统一在前面加一个空格
# .lstrip() 的作用:
# result_decorate 阶段可能已在文本前加了空格或换行,
# 直接拼接会导致双空白
# 统一用 " " 替换所有前导空白,确保 @ 与正文之间仅有一个空格
# 避免直接修改 segment.text 产生副作用,直接修改转换后的字典
if "data" in d and "text" in d["data"]:
d["data"]["text"] = " " + d["data"]["text"].lstrip()
prev_is_at = False
ret.append(d)


else:
# 非 At、非 Plain 的组件(Image、Record、Video、File 等)
if prev_is_at:
# At 后紧跟媒体组件,插入一个空格文本段防止粘连
# 例如:[At] [Image] → [At] [空格] [Image]
ret.append({"type": "text", "data": {"text": " "}})
prev_is_at = False

d = await AiocqhttpMessageEvent._from_segment_to_dict(segment)
ret.append(d)

return ret

@classmethod
Expand Down
Loading