Skip to content

feat(misskey): inject parent note as context when bot is mentioned in a reply#7893

Open
HePudding wants to merge 3 commits intoAstrBotDevs:masterfrom
HePudding:master
Open

feat(misskey): inject parent note as context when bot is mentioned in a reply#7893
HePudding wants to merge 3 commits intoAstrBotDevs:masterfrom
HePudding:master

Conversation

@HePudding
Copy link
Copy Markdown

@HePudding HePudding commented Apr 29, 2026

背景 / 动机

目前 Misskey 适配器在收到 mention / reply / quote 通知后,只会把当前这条评论/回复的文本和附件转换为 AstrBotMessage 给 LLM。父帖(被回复的原帖、被引用的原帖)内容并不会进入上下文。

这导致一个常见场景跑不通:用户在某条帖子下评论并 @机器人讨论这条帖子,机器人完全不知道大家在聊什么,只能干答评论本身。这次改动让 Misskey 上的 @机器人在评论区里也能拿到原帖语境,给出针对性的回复。

改动概览

  • 新增 MisskeyAPI.get_note(note_id):调用 notes/show 作为通知 payload 不展开父帖时的回退。私密帖 / 已删 / 未联邦化的远端帖在此端点会 403/404,已降级为 debug 日志,不刷屏不阻断。
  • 新增工具函数 summarize_note_for_context:把一个 note 对象格式化成纯文本摘要,含作者(远端用户用 @user@host acct)、CW 警告、文本(可截断)、附件文本描述、投票(复用 format_poll)。故意不生成 Comp.Image / Comp.Video,避免 LLM 把父帖图片误当作本次输入的多模态内容。
  • MisskeyPlatformAdapter_resolve_parent_note + _build_parent_note_context
    • 优先用 note.reply / note.renote 的展开对象,缺失时走 get_note
    • 同层 reply + renote 同时存在(reply-with-quote)时两块都注入;
    • depth ≥ 2 主要靠 API 拉取(Misskey 通常只展开一层);
    • 含 visited 集合防自指环;
    • 顶层父帖作者就是机器人自己时整段跳过,避免反馈循环。
  • 修改 convert_message:父帖块作为 Comp.Plain 加入消息链最前,并以 parent_ctx + body 形式拼接 message_str,避免 \n---\n 被原本的空格 join 污染。
  • 不影响 convert_chat_message / convert_room_message(私聊与群聊不存在 reply 链)。

配置项(默认开启,可关)

Key 默认 说明
misskey_include_reply_context True 总开关
misskey_reply_context_max_depth 1 向上追溯层数(1–5)。>1 会带来串行 API 调用,拉高响应延迟
misskey_reply_context_max_text_length 500 单层正文截断长度,下限 50;填 -1 表示不限制

三项均同步到 default.py schema 与 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. 普通 reply(payload 已展开父帖)—— 注入正常。
  2. 回复机器人自己的帖 —— 整段跳过。
  3. 仅有 `replyId` —— 走 `get_note` 拉取后注入。
  4. reply + renote 同时存在 —— 两块都注入,顺序正确。
  5. 配置 `misskey_include_reply_context = False` —— 行为退回到改动前。
  6. `get_note` 返回 None(私密帖 / 已删)—— 链截断,不阻断主流程。

另对 `-1`(不截断)/ 0–49(自动 clamp 到 50)/ 1000(保留)/ 非法字符串(回落 500)做了配置 clamp 验证。

`uv tool run ruff check` + `ruff format --check` 全过。

不在本次范围

  • 不缓存 `notes/show`。
  • 不做线程展开(fetch 整个 thread of replies),仅追溯祖先链。
  • 不把父帖多模态送进 LLM 多模态通道(留待后续按需开关)。

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:

  • Add configurable injection of replied/quoted Misskey parent notes as plain-text context when converting timeline messages for the LLM.
  • Introduce a Misskey API helper to fetch note details by ID for cases where parent notes are not expanded in notification payloads.
  • Provide a utility to summarize a Misskey note (author, content warning, truncated text, attachments, polls) into a text-only block for context.

Enhancements:

  • Extend Misskey adapter configuration with options to enable/disable reply context, control traversal depth of reply/renote chains, and set per-layer text truncation length, including validation and sane defaults.
  • Update default config and dashboard i18n metadata (zh-CN, en-US, ru-RU) to expose the new Misskey reply-context settings to users.

… 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.
@dosubot dosubot Bot added size:L This PR changes 100-499 lines, ignoring generated files. area:platform The bug / feature is about IM platform adapter, such as QQ, Lark, Telegram, WebChat and so on. labels Apr 29, 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 found 3 issues, and left some high level feedback:

  • 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.
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>

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/misskey/misskey_adapter.py Outdated
Comment thread astrbot/core/platform/sources/misskey/misskey_adapter.py Outdated
Comment thread astrbot/core/platform/sources/misskey/misskey_api.py
Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

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

💡 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
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge 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 👍 / 👎.

Comment on lines +735 to +736
and isinstance(current.get("renote"), dict)
):
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge 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 个回归用例。
@dosubot dosubot Bot removed the size:L This PR changes 100-499 lines, ignoring generated files. label Apr 29, 2026
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 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.

Comment on lines +688 to +755
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 "")
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

为了提高代码的可读性和可维护性,建议将重复的笔记摘要生成和块添加逻辑提取到一个内部辅助函数中。根据仓库规范,当实现类似功能(如处理不同类型的引用)时,应重构为共享辅助函数以避免代码重复。

    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
  1. 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.

@dosubot dosubot Bot added the size:XL This PR changes 500-999 lines, ignoring generated files. label Apr 29, 2026
抽出局部 helper append_summary_block,主父帖与 reply-with-quote 引用帖
两处共用,去掉「summarize_note_for_context + label 拼接 + blocks.append」
的重复代码。行为不变,11/11 smoke 通过。

地址 Gemini code review 提出的 helper 抽取建议。
Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

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

💡 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
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge 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 👍 / 👎.

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:XL This PR changes 500-999 lines, ignoring generated files.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant