fix(aiocqhttp): 修复 At 组件与后续内容的间距问题#8561
Conversation
- 确保 At 组件与 Plain 文本之间有一个空格分隔,避免粘连 - 处理 At 组件后紧跟媒体组件时的空格插入 - 跳过纯空白 Plain 文本,重置 At 标志位
There was a problem hiding this comment.
Hey - I've left some high level feedback:
- In
_parse_onebot_json,segment.textis mutated in-place whenprev_is_atisTrue, which can unexpectedly alter the originalMessageChainfor 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
Plainsegments and also resetsprev_is_atmeans anAtfollowed by a whitespace-onlyPlainand then non-whitespacePlainwill 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.Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.
There was a problem hiding this comment.
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.
| 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) |
There was a problem hiding this comment.
直接修改 segment.text 会对传入的 message_chain 中的 Plain 对象产生副作用。如果该消息链在后续的其他平台适配器、插件钩子或日志记录中被复用,其内容将被非预期地篡改。
建议在调用 _from_segment_to_dict 转换后,直接修改生成的字典 d,从而避免对原始 segment 对象的就地修改(In-place mutation)。
| 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) |
- 统一处理 At 组件与后续内容之间的空格,避免多余换行 - 确保最终渲染为 "@用户 你好" 而非 "@用户\n你好" - 跳过纯空白文本,避免影响消息链的原始结构
|
@gemini-code-assist review |
There was a problem hiding this comment.
Hey - I've found 3 issues, and left some high level feedback:
- In
_parse_onebot_json, the comment explicitly says not to modifysegment.text, but the code doessegment.text = " " + segment.text.lstrip()before buildingtext; consider relying solely on a localtextvariable and leaving the originalsegmentuntouched to avoid side effects on the originalMessageChain. - The docstring for
_parse_onebot_jsonmentions "纯空白 Plain(如仅含换行/空格):跳过,重置 At 标志位", but the implementation justcontinues without resettingprev_is_at; either update the docstring or adjust the logic so the behavior and documentation are consistent. - In
result_decorate.stage.process, replacingresult.chain[1]with a newPlaininstance may drop any additional state on the original segment; ifPlaincan carry more than justtext, it may be safer to prepend the space toresult.chain[1].textinstead 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>Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.
There was a problem hiding this comment.
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.
| segment.text = " " + segment.text.lstrip() | ||
| # 注意:不修改 segment.text,避免污染原始 MessageChain(影响 hook 等消费者) | ||
| text = " " + segment.text.lstrip() | ||
| prev_is_at = False | ||
| ret.append({"type": "text", "data": {"text": text}}) |
There was a problem hiding this comment.
在第 106 行中,代码直接修改了 segment.text,这与第 107 行的注释(# 注意:不修改 segment.text,避免污染原始 MessageChain)相矛盾。直接修改 segment.text 会导致原始的 MessageChain 被污染,从而可能影响后续的 hook 或其他消息消费者。
建议删除第 106 行,仅保留局部变量 text 的赋值和使用。
| 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 标志位
Modifications / 改动点
close #8519
Screenshots or Test Results / 运行截图或测试结果
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.txtandpyproject.toml./ 我确保没有引入新依赖库,或者引入了新依赖库的同时将其添加到
requirements.txt和pyproject.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:
Enhancements: