feat(misskey): inject parent note as context when bot is mentioned in a reply#7893
feat(misskey): inject parent note as context when bot is mentioned in a reply#7893HePudding wants to merge 3 commits intoAstrBotDevs:masterfrom
Conversation
… a reply When a user replies to or quotes a Misskey post and @mentions the bot, the adapter now surfaces the parent note's text (with author handle, optional CW, attachment list and poll summary) as a plain-text prefix on the AstrBotMessage, so the LLM can respond with awareness of the post being discussed. - Add MisskeyAPI.get_note for notes/show fallback (silent on 403/404). - Add summarize_note_for_context util: acct-style remote handles, CW awareness, attachment text descriptions only (no Comp.Image to avoid the LLM treating parent images as the user's input). - Walk reply / renote chain up to a configurable depth, with cycle detection and a top-level skip when the parent author is the bot itself. Reply-with-quote injects both blocks at depth 0. - Three new config keys: misskey_include_reply_context (default on), misskey_reply_context_max_depth (default 1, max 5), misskey_reply_context_max_text_length (default 500, -1 = unlimited). Surfaced in config schema and zh-CN / en-US / ru-RU dashboard i18n.
There was a problem hiding this comment.
Hey - I've found 3 issues, and left some high level feedback:
- In
_resolve_parent_notethedepthparameter is never used despite being mentioned in the docstring; either make use of it (e.g., to change lookup strategy by depth) or remove it from the signature and documentation to avoid confusion. - Several user-facing strings inside the injected context (e.g., labels like
被回复的原帖,被引用/转发的原帖,作者:,内容警告(CW):,内容: (无文本),未知用户) are hard-coded in Chinese; consider centralizing these labels and making them locale-aware or at least consistent with the Misskey instance/user language settings. - In
_build_parent_note_context, you can add an early return ifraw_datahas neitherreply,replyId,renote, norrenoteIdto avoid entering the loop and extra work for standalone posts that cannot have parent context.
Prompt for AI Agents
Please address the comments from this code review:
## Overall Comments
- In `_resolve_parent_note` the `depth` parameter is never used despite being mentioned in the docstring; either make use of it (e.g., to change lookup strategy by depth) or remove it from the signature and documentation to avoid confusion.
- Several user-facing strings inside the injected context (e.g., labels like `被回复的原帖`, `被引用/转发的原帖`, `作者:`, `内容警告(CW):`, `内容: (无文本)`, `未知用户`) are hard-coded in Chinese; consider centralizing these labels and making them locale-aware or at least consistent with the Misskey instance/user language settings.
- In `_build_parent_note_context`, you can add an early return if `raw_data` has neither `reply`, `replyId`, `renote`, nor `renoteId` to avoid entering the loop and extra work for standalone posts that cannot have parent context.
## Individual Comments
### Comment 1
<location path="astrbot/core/platform/sources/misskey/misskey_adapter.py" line_range="657-660" />
<code_context>
return await super().send_by_session(session, message_chain)
+ async def _resolve_parent_note(
+ self,
+ current: dict[str, Any],
+ depth: int,
+ ) -> tuple[dict[str, Any] | None, str | None]:
+ """根据当前 note 解析父帖。返回 (parent_dict, relation_label) 或 (None, None)。
</code_context>
<issue_to_address>
**suggestion (bug_risk):** The `depth` parameter is never used in `_resolve_parent_note`, which can be confusing and may indicate missing logic.
The signature and docstring promise depth-specific behavior, but the implementation never uses `depth`. If depth is obsolete, please remove it and update the docstring. If it should affect lookup (e.g., embedded objects at depth 0 vs. IDs/API at deeper levels), that logic appears to be missing and should be added or clarified.
Suggested implementation:
```python
async def _resolve_parent_note(
self,
current: dict[str, Any],
) -> tuple[dict[str, Any] | None, str | None]:
"""根据当前 note 解析父帖。返回 (parent_dict, relation_label) 或 (None, None)。
优先使用 payload 中已展开的对象(reply / renote);若不存在则在有 API 客户端可用时回退到 API 拉取。
reply 和 renote 同时存在(reply-with-quote)时返回 reply,调用方需要单独处理 renote。
"""
```
1. 搜索所有对 `_resolve_parent_note` 的调用,移除传入的 `depth` 参数,例如将 `_resolve_parent_note(note, depth)` 改为 `_resolve_parent_note(note)`。
2. 如果调用方的逻辑依赖 `depth`(比如根据深度决定是否继续向上解析父帖),应在调用层面保留并使用 `depth`,但不再传入 `_resolve_parent_note`,仅用于控制是否继续调用该方法或控制递归层数。
</issue_to_address>
### Comment 2
<location path="astrbot/core/platform/sources/misskey/misskey_adapter.py" line_range="731-740" />
<code_context>
+ # depth=0 且当前是 reply:如果还有 renote(reply-with-quote),也补上
</code_context>
<issue_to_address>
**suggestion:** Reply-with-quote handling only works when `renote` is fully embedded, and doesn't fall back to `renoteId`.
This branch assumes `current.get("renote")` is a dict. When Misskey sends only `renoteId` (no expanded `renote`), the quoted post is silently skipped and no API lookup is attempted, even though `_resolve_parent_note` already handles `renoteId`. Consider mirroring that behavior here: prefer `renote` when present, otherwise use `renoteId` with `self.api.get_note` so quoted posts are handled consistently across payload shapes.
Suggested implementation:
```python
# depth=0 且当前是 reply:如果还有 renote(reply-with-quote),也补上
# 当 payload 只包含 renoteId 而没有展开 renote 对象时,尝试通过 API 拉取,以保证一致性
if depth == 0 and relation == "被回复的原帖":
renote_obj = None
# 优先使用已经展开的 renote 对象
if isinstance(current.get("renote"), dict):
renote_obj = current["renote"]
else:
# 否则回退到 renoteId,尝试通过 API 获取
renote_id_from_payload = current.get("renoteId")
if isinstance(renote_id_from_payload, str) and renote_id_from_payload:
try:
renote_obj = self.api.get_note(renote_id_from_payload)
except Exception:
renote_obj = None
if isinstance(renote_obj, dict):
renote_id = str(renote_obj.get("id") or "")
if renote_id and renote_id not in visited:
visited.add(renote_id)
quote_summary = summarize_note_for_context(
renote_obj,
max_text_length=self.reply_context_max_text_length,
)
if quote_summary:
blocks.append(f"[被引用的原帖]\n{quote_summary}")
```
If the surrounding code differs from the assumed original (e.g. variable names or the exact text of the label "[被引用的原帖]"), adjust the SEARCH block to match your current implementation, or reapply the same logic in your local style. Also confirm that `self.api.get_note` is available and returns a dict-like note object; if it returns a wrapper object, access the underlying note before passing it to `summarize_note_for_context`.
</issue_to_address>
### Comment 3
<location path="astrbot/core/platform/sources/misskey/misskey_api.py" line_range="759-763" />
<code_context>
+ self.include_reply_context = bool(
+ self.config.get("misskey_include_reply_context", True),
+ )
+ try:
+ self.reply_context_max_depth = max(
+ 0,
</code_context>
<issue_to_address>
**issue (bug_risk):** Catching all exceptions in `get_note` may unintentionally swallow `CancelledError` and other critical exceptions.
By catching `Exception` around `_make_request`, cancellation signals like `asyncio.CancelledError` get converted into a debug log and `None`, which can break shutdown/timeout handling. Please re-raise `CancelledError` (and any other critical exceptions you identify) while still treating normal HTTP/API failures as recoverable and logging them at debug level.
</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.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 857d9ba901
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| if message_parts | ||
| else "" | ||
| ) | ||
| message.message_str = parent_ctx + body if parent_ctx else body |
There was a problem hiding this comment.
Preserve command text start when prepending parent context
Prepending parent_ctx directly into message.message_str causes command handlers to stop matching in reply mentions when this feature is enabled (default True). In the wake pipeline, @bot still sets is_at_or_wake_command, but command filters require the message text to start with the command token (see astrbot/core/star/filter/command.py), so inputs like @bot /help in a reply become "[parent context]...\n---\n/help" and no longer trigger command plugins.
Useful? React with 👍 / 👎.
| and isinstance(current.get("renote"), dict) | ||
| ): |
There was a problem hiding this comment.
Fetch quoted parent when only renoteId is present
In reply-with-quote cases, the second parent note is only injected if current["renote"] is already an expanded object. When payloads contain replyId + renoteId but no expanded renote object, this branch is skipped and the quoted parent context is silently dropped, despite the new fallback API fetch logic intended for unexpanded parents.
Useful? React with 👍 / 👎.
- 父帖上下文改为追加到 message_str 尾部,避免破坏 wake_prefix / 星标命令 startswith 匹配(Codex P1)。LLM 主路径直接从 message_str 读完整 prompt(astr_main_agent.py:1174 / third_party.py:315),尾部 追加不影响 LLM;chain 里的死代码 Comp.Plain(parent_ctx) 一并移除。 - get_note 显式重抛 asyncio.CancelledError,避免吞掉取消信号导致 shutdown / 超时挂起(Sourcery AstrBotDevs#3)。普通 HTTP 异常仍降级到 debug。 - reply-with-quote 仅有 renoteId 时也通过 API 拉取引用帖(Sourcery AstrBotDevs#2 / Codex P2)。抽出 _resolve_reply_target / _resolve_renote_target 两个纯解析方法,_resolve_parent_note 现在是它们的组合并删掉了未使用 的 depth 形参(Sourcery AstrBotDevs#1)。 - _build_parent_note_context 对无 reply/replyId/renote/renoteId 的 独立帖子早返回,避免空循环(Sourcery 反馈 c)。 - 新增 scripts/smoke_misskey_parent_ctx.py:11 个回归用例。
There was a problem hiding this comment.
Code Review
This pull request implements parent note context injection for Misskey, allowing the bot to include text from replied-to or quoted posts in its context. It adds configuration options for context depth and length, a note summarization utility, and logic to trace note chains via the API. Feedback suggests refactoring the context-building logic to extract a helper function for note summarization and block appending to reduce code duplication.
| renote_id = current.get("renoteId") | ||
| if renote_id and self.api: | ||
| fetched = await self.api.get_note(str(renote_id)) | ||
| if isinstance(fetched, dict): | ||
| return fetched | ||
| return None | ||
|
|
||
| async def _resolve_parent_note( | ||
| self, | ||
| current: dict[str, Any], | ||
| ) -> tuple[dict[str, Any] | None, str | None]: | ||
| """解析当前 note 的父帖(按优先级返回首个候选)。 | ||
|
|
||
| 优先返回 reply 目标(被回复的原帖);reply 不存在时回退到 renote 目标 | ||
| (被引用/转发的原帖)。reply-with-quote 场景:返回 reply,调用方需要 | ||
| 再单独走 _resolve_renote_target 取引用帖。 | ||
| """ | ||
| reply_parent = await self._resolve_reply_target(current) | ||
| if reply_parent is not None: | ||
| return reply_parent, "被回复的原帖" | ||
| renote_parent = await self._resolve_renote_target(current) | ||
| if renote_parent is not None: | ||
| return renote_parent, "被引用/转发的原帖" | ||
| return None, None | ||
|
|
||
| async def _build_parent_note_context( | ||
| self, | ||
| raw_data: dict[str, Any], | ||
| ) -> str: | ||
| """从一条 note 出发,向上追溯 reply / renote 链,返回拼好的纯文本上下文。 | ||
|
|
||
| - depth=0 时如果同时存在 reply + renote(reply-with-quote),两个都注入。 | ||
| - 顶层(depth=0)父帖作者是机器人自己时整段跳过,避免反馈循环。 | ||
| - 链中循环或 API 失败时静默截断,不阻断消息处理。 | ||
| - 返回值会被作为后缀拼到 ``message_str`` 末尾,因此自带前导分隔符 | ||
| ``\\n\\n---\\n``,让 LLM 看到的 prompt 形如「用户文本 \\n--- 父帖摘要」。 | ||
| 放尾部而非头部是为了不破坏 wake_prefix 与命令前缀的 startswith 匹配。 | ||
| """ | ||
| if self.reply_context_max_depth <= 0: | ||
| return "" | ||
|
|
||
| # 既无 reply/replyId 又无 renote/renoteId 的独立帖子,没有父帖可追,直接退出, | ||
| # 避免空循环以及无谓的 API 调用。 | ||
| if not ( | ||
| raw_data.get("reply") | ||
| or raw_data.get("replyId") | ||
| or raw_data.get("renote") | ||
| or raw_data.get("renoteId") | ||
| ): | ||
| return "" | ||
|
|
||
| blocks: list[str] = [] | ||
| visited: set[str] = set() | ||
| current = raw_data | ||
| labelled_by_depth = self.reply_context_max_depth > 1 | ||
|
|
||
| for depth in range(self.reply_context_max_depth): | ||
| parent, relation = await self._resolve_parent_note(current) | ||
| if not isinstance(parent, dict): | ||
| break | ||
|
|
||
| parent_id = str(parent.get("id") or "") | ||
| if not parent_id or parent_id in visited: | ||
| break | ||
| visited.add(parent_id) | ||
|
|
||
| if depth == 0: | ||
| parent_uid = str((parent.get("user") or {}).get("id") or "") |
There was a problem hiding this comment.
为了提高代码的可读性和可维护性,建议将重复的笔记摘要生成和块添加逻辑提取到一个内部辅助函数中。根据仓库规范,当实现类似功能(如处理不同类型的引用)时,应重构为共享辅助函数以避免代码重复。
async def _build_parent_note_context(
self,
raw_data: dict[str, Any],
) -> str:
"""从一条 note 出发,向上追溯 reply / renote 链,返回拼好的纯文本上下文。
- depth=0 时如果同时存在 reply + renote(reply-with-quote),两个都注入。
- 顶层(depth=0)父帖作者是机器人自己时整段跳过,避免反馈循环。
- 链中循环或 API 失败时静默截断,不阻断消息处理。
"""
if self.reply_context_max_depth <= 0:
return ""
blocks: list[str] = []
visited: set[str] = set()
current = raw_data
labelled_by_depth = self.reply_context_max_depth > 1
def _summarize_and_append(note: dict[str, Any], relation: str, depth_for_label: int):
summary = summarize_note_for_context(
note,
max_text_length=self.reply_context_max_text_length,
)
if summary:
label = relation
if labelled_by_depth:
label = f"{label} - 第{depth_for_label + 1}层"
blocks.append(f"[{label}]\n{summary}")
for depth in range(self.reply_context_max_depth):
parent, relation = await self._resolve_parent_note(current, depth)
if not isinstance(parent, dict):
break
parent_id = str(parent.get("id") or "")
if not parent_id or parent_id in visited:
break
visited.add(parent_id)
if depth == 0:
parent_uid = str((parent.get("user") or {}).get("id") or "")
if parent_uid and parent_uid == self.bot_self_id:
return ""
_summarize_and_append(parent, relation or "被回复的原帖", depth)
# depth=0 且当前是 reply:如果还有 renote(reply-with-quote),也补上
if (
depth == 0
and relation == "被回复的原帖"
and isinstance(current.get("renote"), dict)
):
renote_obj = current["renote"]
renote_id = str(renote_obj.get("id") or "")
if renote_id and renote_id not in visited:
visited.add(renote_id)
_summarize_and_append(renote_obj, "被引用/转发的原帖", 0)
current = parent
if not blocks:
return ""
return "\n\n".join(blocks) + "\n---\n"References
- When implementing similar functionality for different cases (e.g., direct vs. quoted attachments), refactor the logic into a shared helper function to avoid code duplication.
抽出局部 helper append_summary_block,主父帖与 reply-with-quote 引用帖 两处共用,去掉「summarize_note_for_context + label 拼接 + blocks.append」 的重复代码。行为不变,11/11 smoke 通过。 地址 Gemini code review 提出的 helper 抽取建议。
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: b790f62ed5
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| if message_parts | ||
| else "" | ||
| ) | ||
| message.message_str = body + parent_ctx if parent_ctx else body |
There was a problem hiding this comment.
Keep parent context out of command argument parsing
Appending parent_ctx directly to message.message_str causes command filters to parse parent-note text as user-supplied arguments. In CommandFilter.filter the whole message is whitespace-normalized and tokenized for params, so reply context tokens after --- are consumed as optional/defaulted args (e.g. /provider can receive non-None garbage values instead of behaving like no-arg invocation), which changes command behavior only on Misskey reply mentions with this feature enabled. This regression comes from constructing message_str as body + parent_ctx here rather than keeping context in a non-command-parsed channel.
Useful? React with 👍 / 👎.
背景 / 动机
目前 Misskey 适配器在收到
mention/reply/quote通知后,只会把当前这条评论/回复的文本和附件转换为AstrBotMessage给 LLM。父帖(被回复的原帖、被引用的原帖)内容并不会进入上下文。这导致一个常见场景跑不通:用户在某条帖子下评论并 @机器人讨论这条帖子,机器人完全不知道大家在聊什么,只能干答评论本身。这次改动让 Misskey 上的 @机器人在评论区里也能拿到原帖语境,给出针对性的回复。
改动概览
MisskeyAPI.get_note(note_id):调用notes/show作为通知 payload 不展开父帖时的回退。私密帖 / 已删 / 未联邦化的远端帖在此端点会 403/404,已降级为debug日志,不刷屏不阻断。summarize_note_for_context:把一个 note 对象格式化成纯文本摘要,含作者(远端用户用@user@hostacct)、CW 警告、文本(可截断)、附件文本描述、投票(复用format_poll)。故意不生成Comp.Image / Comp.Video,避免 LLM 把父帖图片误当作本次输入的多模态内容。MisskeyPlatformAdapter加_resolve_parent_note+_build_parent_note_context:note.reply/note.renote的展开对象,缺失时走get_note;convert_message:父帖块作为Comp.Plain加入消息链最前,并以parent_ctx + body形式拼接message_str,避免\n---\n被原本的空格 join 污染。convert_chat_message/convert_room_message(私聊与群聊不存在 reply 链)。配置项(默认开启,可关)
misskey_include_reply_contextTruemisskey_reply_context_max_depth1misskey_reply_context_max_text_length500-1表示不限制三项均同步到
default.pyschema 与 zh-CN / en-US / ru-RU 三套 dashboard i18n。注入格式示例
```
[被回复的原帖]
作者: @bob@example.com (Bob)
内容警告(CW): spoiler
内容: 原帖正文……
附件: 图片[a.png]
[投票] 单选 选项: (1) A [3票], (2) B [1票]
@bot 你怎么看
```
`reply` + `renote` 同存时按 `reply` → `renote` 顺序输出两个块;多层时块标签会带「第 N 层」后缀。
测试
本地构造了 6 组场景 smoke 测试,全部通过:
另对 `-1`(不截断)/ 0–49(自动 clamp 到 50)/ 1000(保留)/ 非法字符串(回落 500)做了配置 clamp 验证。
`uv tool run ruff check` + `ruff format --check` 全过。
不在本次范围
Summary by Sourcery
Inject parent note context into Misskey mentions so bots can respond with awareness of the original post being replied to or quoted.
New Features:
Enhancements: