Skip to content

fix(aiocqhttp): 修复 At 组件与后续内容的间距问题#8561

Open
NayukiChiba wants to merge 3 commits into
AstrBotDevs:masterfrom
NayukiChiba:fix/8519-aiocqhttp-newline
Open

fix(aiocqhttp): 修复 At 组件与后续内容的间距问题#8561
NayukiChiba wants to merge 3 commits into
AstrBotDevs:masterfrom
NayukiChiba:fix/8519-aiocqhttp-newline

Conversation

@NayukiChiba
Copy link
Copy Markdown
Contributor

@NayukiChiba NayukiChiba commented Jun 3, 2026

Modifications / 改动点

  • 确保 At 组件与 Plain 文本之间有一个空格分隔,避免粘连
  • 处理 At 组件后紧跟媒体组件时的空格插入
  • 跳过纯空白 Plain 文本,重置 At 标志位

close #8519

  • This is NOT a breaking change. / 这不是一个破坏性变更。

Screenshots or Test Results / 运行截图或测试结果

test session starts 
platform win32 -- Python 3.12.12, pytest-9.0.3, pluggy-1.6.0
rootdir: D:\Nayey\Code\NayukiChiba\Astrbot
configfile: pyproject.toml
plugins: anyio-4.13.0, asyncio-1.4.0, cov-7.1.0
asyncio: mode=Mode.STRICT, debug=False, asyncio_default_fixture_loop_scope=None, asyncio_default_test_loop_scope=function
collected 6 items                                                                                     

tests\unit\test_aiocqhttp_reply.py ...... [100%]
6 passed in 2.16s 


Checklist / 检查清单

  • 😊 If there are new features added in the PR, I have discussed it with the authors through issues/emails, etc.
    / 如果 PR 中有新加入的功能,已经通过 Issue / 邮件等方式和作者讨论过。

  • 👀 My changes have been well-tested, and "Verification Steps" and "Screenshots" have been provided above.
    / 我的更改经过了良好的测试,并已在上方提供了“验证步骤”和“运行截图”

  • 🤓 I have ensured that no new dependencies are introduced, OR if new dependencies are introduced, they have been added to the appropriate locations in requirements.txt and pyproject.toml.
    / 我确保没有引入新依赖库,或者引入了新依赖库的同时将其添加到 requirements.txtpyproject.toml 文件相应位置。

  • 😮 My changes do not introduce malicious code.
    / 我的更改没有引入恶意代码。

Summary by Sourcery

Adjust OneBot message conversion and result decoration to correctly handle spacing around At mentions and subsequent content in aiocqhttp messages.

Bug Fixes:

  • Ensure a single space is inserted between At mentions and following plain text to prevent text from sticking or double spaces.
  • Insert a separating space segment when an At mention is followed by non-text media components to avoid visual concatenation.
  • Skip purely whitespace plain text segments while resetting At-state to prevent them from interfering with spacing logic.

Enhancements:

  • Refine result decoration so that At mentions are followed by a space instead of a forced newline, letting platform adapters render mentions inline.

- 确保 At 组件与 Plain 文本之间有一个空格分隔,避免粘连
- 处理 At 组件后紧跟媒体组件时的空格插入
- 跳过纯空白 Plain 文本,重置 At 标志位
@dosubot dosubot Bot added size:M This PR changes 30-99 lines, ignoring generated files. area:platform The bug / feature is about IM platform adapter, such as QQ, Lark, Telegram, WebChat and so on. labels Jun 3, 2026
Copy link
Copy Markdown
Contributor

@sourcery-ai sourcery-ai Bot left a comment

Choose a reason for hiding this comment

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

Hey - I've left some high level feedback:

  • In _parse_onebot_json, segment.text is mutated in-place when prev_is_at is True, which can unexpectedly alter the original MessageChain for other consumers; consider working on a local text variable or cloning the segment instead of modifying the shared object.
  • The logic that skips pure-whitespace Plain segments and also resets prev_is_at means an At followed by a whitespace-only Plain and then non-whitespace Plain will render without the intended single space after the mention; double-check whether the flag should really be cleared in this case or preserved until the next non-empty content.
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- In `_parse_onebot_json`, `segment.text` is mutated in-place when `prev_is_at` is `True`, which can unexpectedly alter the original `MessageChain` for other consumers; consider working on a local text variable or cloning the segment instead of modifying the shared object.
- The logic that skips pure-whitespace `Plain` segments and also resets `prev_is_at` means an `At` followed by a whitespace-only `Plain` and then non-whitespace `Plain` will render without the intended single space after the mention; double-check whether the flag should really be cleared in this case or preserved until the next non-empty content.

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

Copy link
Copy Markdown
Contributor

@gemini-code-assist gemini-code-assist Bot left a comment

Choose a reason for hiding this comment

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

Code Review

This pull request refactors the spacing logic between At components and subsequent message segments to ensure they are separated by a single space rather than a newline or multiple spaces. The review feedback points out that directly mutating the Plain segment objects in-place can cause unintended side effects if those objects are reused across different adapters or hooks. It is recommended to avoid in-place mutations by either instantiating new Plain objects or modifying the serialized dictionary representation directly.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment thread astrbot/core/pipeline/result_decorate/stage.py Outdated
Comment on lines +99 to 109
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)
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)

@NayukiChiba NayukiChiba marked this pull request as draft June 3, 2026 15:12
- 统一处理 At 组件与后续内容之间的空格,避免多余换行
- 确保最终渲染为 "@用户 你好" 而非 "@用户\n你好"
- 跳过纯空白文本,避免影响消息链的原始结构
@NayukiChiba NayukiChiba marked this pull request as ready for review June 3, 2026 15:41
@NayukiChiba
Copy link
Copy Markdown
Contributor Author

@gemini-code-assist review

Copy link
Copy Markdown
Contributor

@sourcery-ai sourcery-ai Bot left a comment

Choose a reason for hiding this comment

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

Hey - I've found 3 issues, and left some high level feedback:

  • In _parse_onebot_json, the comment explicitly says not to modify segment.text, but the code does segment.text = " " + segment.text.lstrip() before building text; consider relying solely on a local text variable and leaving the original segment untouched to avoid side effects on the original MessageChain.
  • The docstring for _parse_onebot_json mentions "纯空白 Plain(如仅含换行/空格):跳过,重置 At 标志位", but the implementation just continues without resetting prev_is_at; either update the docstring or adjust the logic so the behavior and documentation are consistent.
  • In result_decorate.stage.process, replacing result.chain[1] with a new Plain instance may drop any additional state on the original segment; if Plain can carry more than just text, it may be safer to prepend the space to result.chain[1].text instead of constructing a new object.
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- In `_parse_onebot_json`, the comment explicitly says not to modify `segment.text`, but the code does `segment.text = " " + segment.text.lstrip()` before building `text`; consider relying solely on a local `text` variable and leaving the original `segment` untouched to avoid side effects on the original `MessageChain`.
- The docstring for `_parse_onebot_json` mentions "纯空白 Plain(如仅含换行/空格):跳过,重置 At 标志位", but the implementation just `continue`s without resetting `prev_is_at`; either update the docstring or adjust the logic so the behavior and documentation are consistent.
- In `result_decorate.stage.process`, replacing `result.chain[1]` with a new `Plain` instance may drop any additional state on the original segment; if `Plain` can carry more than just `text`, it may be safer to prepend the space to `result.chain[1].text` instead of constructing a new object.

## Individual Comments

### Comment 1
<location path="astrbot/core/platform/sources/aiocqhttp/aiocqhttp_message_event.py" line_range="106-108" />
<code_context>
+                    # result_decorate 阶段可能已在文本前加了空格或换行,
+                    # 直接拼接会导致 "@用户  \n你好" 这样的双空白
+                    # 统一用 " " 替换所有前导空白,确保 @ 与正文之间仅有一个空格
+                    segment.text = " " + segment.text.lstrip()
+                    # 注意:不修改 segment.text,避免污染原始 MessageChain(影响 hook 等消费者)
+                    text = " " + segment.text.lstrip()
+                    prev_is_at = False
+                    ret.append({"type": "text", "data": {"text": text}})
</code_context>
<issue_to_address>
**issue (bug_risk):** Avoid mutating `segment.text` and computing the leading space twice; current logic conflicts with the comment and may yield unintended content.

Here you both mutate `segment.text` and then recompute `text` from the mutated value:

1. This contradicts the comment stating that `segment.text` should not be modified in-place.
2. Because `segment.text` is already set to `" " + segment.text.lstrip()`, doing `text = " " + segment.text.lstrip()` applies `lstrip` twice and can over-trim leading whitespace.
3. Changing `segment.text` may affect other code that still references this `Plain` in the original `MessageChain`.

Instead, keep `segment.text` unchanged, store its original value in a local variable, and compute `text` once from that value.
</issue_to_address>

### Comment 2
<location path="astrbot/core/platform/sources/aiocqhttp/aiocqhttp_message_event.py" line_range="78" />
<code_context>
+        - At + Plain 文本:确保一个空格分隔,避免粘连或双空格
+        - At + 非 Plain(图片/文件等):插入空格文本段分隔
+        - At 在链末尾:不添加多余空格
+        - 纯空白 Plain(如仅含换行/空格):跳过,重置 At 标志位
+        """
         ret = []
</code_context>
<issue_to_address>
**issue (bug_risk):** Docstring says whitespace Plain resets the At flag, but the implementation keeps `prev_is_at` unchanged.

In the `if not segment.text.strip():` branch you only `continue` and never clear `prev_is_at`, so a sequence like `[At, Plain("\n"), Plain("你好")]` still treats the final `Plain` as directly after the At. This contradicts the documented behavior for “纯空白 Plain”. Please update either the logic (e.g., reset `prev_is_at` for whitespace-only segments) or the docstring so they are consistent.
</issue_to_address>

### Comment 3
<location path="astrbot/core/platform/sources/aiocqhttp/aiocqhttp_message_event.py" line_range="70" />
<code_context>

     @staticmethod
     async def _parse_onebot_json(message_chain: MessageChain):
-        """解析成 OneBot json 格式"""
+        """解析成 OneBot json 格式
</code_context>
<issue_to_address>
**issue (complexity):** Consider extracting the At-spacing logic into small helper functions and avoiding mutation of `segment.text` to simplify control flow and remove duplicated work.

You can keep the current behavior while simplifying the state handling and avoiding mutation/confusing comments.

**1. Don’t mutate `segment.text` & remove duplication**

Right now:

```python
if prev_is_at:
    segment.text = " " + segment.text.lstrip()
    # 注意:不修改 segment.text,避免污染原始 MessageChain(影响 hook 等消费者)
    text = " " + segment.text.lstrip()
    prev_is_at = False
    ret.append({"type": "text", "data": {"text": text}})
    continue
```

This both mutates `segment.text` and does the expensive `" " + segment.text.lstrip()` twice, while the comment says you *don’t* mutate it.

A focused fix that keeps semantics:

```python
def _normalize_plain_after_at(text: str) -> str:
    # 统一在前面加一个空格,并去掉原有前导空白,避免双空格/双换行
    return " " + text.lstrip()
```

Use it in the loop without mutating `segment`:

```python
elif isinstance(segment, Plain):
    if not segment.text.strip():
        # 注意: 不重置 prev_is_at, 避免 [At, Plain("\n"), Plain("你好")] 阻断空格插入
        continue

    if prev_is_at:
        text = _normalize_plain_after_at(segment.text)
        ret.append({"type": "text", "data": {"text": text}})
        prev_is_at = False
        continue

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

This removes the contradictory comment and duplicate `lstrip()` while preserving the exact spacing behavior.

**2. Isolate the “At spacing” rule in a small helper**

The `prev_is_at` flag is currently handled in three branches. You can centralize that logic to make the loop read more linearly:

```python
def _append_with_at_spacing(ret: list[dict], prev_is_at: bool, segment: MessageSegment) -> bool:
    """处理 At 之后的空格插入,返回新的 prev_is_at 值"""
    from graia.ariadne.message.element import Plain, At  # or appropriate import

    if isinstance(segment, At):
        ret.append(asyncio.run(AiocqhttpMessageEvent._from_segment_to_dict(segment)))
        return True

    if isinstance(segment, Plain):
        if not segment.text.strip():
            # 不重置 prev_is_at
            return prev_is_at
        if prev_is_at:
            text = _normalize_plain_after_at(segment.text)
            ret.append({"type": "text", "data": {"text": text}})
            return False
        ret.append(asyncio.run(AiocqhttpMessageEvent._from_segment_to_dict(segment)))
        return False

    # 其他类型
    if prev_is_at:
        ret.append({"type": "text", "data": {"text": " "}})
    ret.append(asyncio.run(AiocqhttpMessageEvent._from_segment_to_dict(segment)))
    return False
```

Then the main loop becomes only orchestration around this rule:

```python
ret: list[dict] = []
prev_is_at = False

for segment in message_chain.chain:
    prev_is_at = await _append_with_at_spacing_async(ret, prev_is_at, segment)

return ret
```

You can adapt this to an async helper (`_append_with_at_spacing_async`) instead of the `asyncio.run` I used for illustration. The key point is that all the spacing rules live in one logically grouped function, and `_parse_onebot_json` reads as “iterate over segments, delegate spacing behavior,” reducing the perceived state-machine complexity while keeping all new behavior intact.
</issue_to_address>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

Comment thread astrbot/core/platform/sources/aiocqhttp/aiocqhttp_message_event.py Outdated
Comment thread astrbot/core/platform/sources/aiocqhttp/aiocqhttp_message_event.py Outdated
Comment thread astrbot/core/platform/sources/aiocqhttp/aiocqhttp_message_event.py
Copy link
Copy Markdown
Contributor

@gemini-code-assist gemini-code-assist Bot left a comment

Choose a reason for hiding this comment

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

Code Review

This pull request refactors the spacing logic around At components to prevent forced newlines and double spaces, particularly within the aiocqhttp platform adapter. A critical issue was identified in aiocqhttp_message_event.py where segment.text is directly mutated, contradicting an inline comment and potentially polluting the original MessageChain for subsequent hooks or consumers.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment on lines +106 to +110
segment.text = " " + segment.text.lstrip()
# 注意:不修改 segment.text,避免污染原始 MessageChain(影响 hook 等消费者)
text = " " + segment.text.lstrip()
prev_is_at = False
ret.append({"type": "text", "data": {"text": text}})
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.

high

在第 106 行中,代码直接修改了 segment.text,这与第 107 行的注释(# 注意:不修改 segment.text,避免污染原始 MessageChain)相矛盾。直接修改 segment.text 会导致原始的 MessageChain 被污染,从而可能影响后续的 hook 或其他消息消费者。

建议删除第 106 行,仅保留局部变量 text 的赋值和使用。

Suggested change
segment.text = " " + segment.text.lstrip()
# 注意:不修改 segment.text,避免污染原始 MessageChain(影响 hook 等消费者)
text = " " + segment.text.lstrip()
prev_is_at = False
ret.append({"type": "text", "data": {"text": text}})
# 注意:不修改 segment.text,避免污染原始 MessageChain(影响 hook 等消费者)
text = " " + segment.text.lstrip()
prev_is_at = False
ret.append({"type": "text", "data": {"text": text}})

- 确保 At + Plain 文本之间有一个空格分隔,避免粘连或双空格
- 在 At 组件与非 Plain 内容之间插入空格文本段分隔
- 修改纯空白 Plain 的处理逻辑,跳过时不重置 At 标志位
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area:platform The bug / feature is about IM platform adapter, such as QQ, Lark, Telegram, WebChat and so on. size:M This PR changes 30-99 lines, ignoring generated files.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug] PR#6238 导致在 QQ(aiocqhttp) 中 @ 后强制换行

1 participant