Skip to content

Commit 6f6dbc4

Browse files
committed
fix(misskey): address review feedback on parent-note context PR
- 父帖上下文改为追加到 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 个回归用例。
1 parent 857d9ba commit 6f6dbc4

3 files changed

Lines changed: 413 additions & 37 deletions

File tree

astrbot/core/platform/sources/misskey/misskey_adapter.py

Lines changed: 76 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -654,35 +654,60 @@ async def _upload_comp(comp) -> object | None:
654654

655655
return await super().send_by_session(session, message_chain)
656656

657-
async def _resolve_parent_note(
657+
async def _resolve_reply_target(
658658
self,
659659
current: dict[str, Any],
660-
depth: int,
661-
) -> tuple[dict[str, Any] | None, str | None]:
662-
"""根据当前 note 解析父帖。返回 (parent_dict, relation_label) 或 (None, None)。
660+
) -> dict[str, Any] | None:
661+
"""解析当前 note 的 reply 目标(被回复的原帖)。
663662
664-
depth==0 时优先用 payload 中已展开的对象(reply / renote);缺失时回退 API。
665-
depth>=1 时通常 payload 不再嵌套展开,主要靠 API 拉取。
666-
reply 和 renote 同时存在(reply-with-quote)时返回 reply,调用方需要单独处理 renote。
663+
优先用 payload 中已展开的 `reply` 对象;缺失时通过 `replyId`
664+
走一次 notes/show API 回退。两者皆无返回 None。
667665
"""
668666
reply_obj = current.get("reply")
669667
if isinstance(reply_obj, dict):
670-
return reply_obj, "被回复的原帖"
668+
return reply_obj
671669
reply_id = current.get("replyId")
672670
if reply_id and self.api:
673671
fetched = await self.api.get_note(str(reply_id))
674672
if isinstance(fetched, dict):
675-
return fetched, "被回复的原帖"
673+
return fetched
674+
return None
675+
676+
async def _resolve_renote_target(
677+
self,
678+
current: dict[str, Any],
679+
) -> dict[str, Any] | None:
680+
"""解析当前 note 的 renote 目标(被引用/转发的原帖)。
676681
682+
优先用 payload 中已展开的 `renote` 对象;缺失时通过 `renoteId`
683+
走一次 notes/show API 回退。两者皆无返回 None。
684+
"""
677685
renote_obj = current.get("renote")
678686
if isinstance(renote_obj, dict):
679-
return renote_obj, "被引用/转发的原帖"
687+
return renote_obj
680688
renote_id = current.get("renoteId")
681689
if renote_id and self.api:
682690
fetched = await self.api.get_note(str(renote_id))
683691
if isinstance(fetched, dict):
684-
return fetched, "被引用/转发的原帖"
692+
return fetched
693+
return None
685694

695+
async def _resolve_parent_note(
696+
self,
697+
current: dict[str, Any],
698+
) -> tuple[dict[str, Any] | None, str | None]:
699+
"""解析当前 note 的父帖(按优先级返回首个候选)。
700+
701+
优先返回 reply 目标(被回复的原帖);reply 不存在时回退到 renote 目标
702+
(被引用/转发的原帖)。reply-with-quote 场景:返回 reply,调用方需要
703+
再单独走 _resolve_renote_target 取引用帖。
704+
"""
705+
reply_parent = await self._resolve_reply_target(current)
706+
if reply_parent is not None:
707+
return reply_parent, "被回复的原帖"
708+
renote_parent = await self._resolve_renote_target(current)
709+
if renote_parent is not None:
710+
return renote_parent, "被引用/转发的原帖"
686711
return None, None
687712

688713
async def _build_parent_note_context(
@@ -694,17 +719,30 @@ async def _build_parent_note_context(
694719
- depth=0 时如果同时存在 reply + renote(reply-with-quote),两个都注入。
695720
- 顶层(depth=0)父帖作者是机器人自己时整段跳过,避免反馈循环。
696721
- 链中循环或 API 失败时静默截断,不阻断消息处理。
722+
- 返回值会被作为后缀拼到 ``message_str`` 末尾,因此自带前导分隔符
723+
``\\n\\n---\\n``,让 LLM 看到的 prompt 形如「用户文本 \\n--- 父帖摘要」。
724+
放尾部而非头部是为了不破坏 wake_prefix 与命令前缀的 startswith 匹配。
697725
"""
698726
if self.reply_context_max_depth <= 0:
699727
return ""
700728

729+
# 既无 reply/replyId 又无 renote/renoteId 的独立帖子,没有父帖可追,直接退出,
730+
# 避免空循环以及无谓的 API 调用。
731+
if not (
732+
raw_data.get("reply")
733+
or raw_data.get("replyId")
734+
or raw_data.get("renote")
735+
or raw_data.get("renoteId")
736+
):
737+
return ""
738+
701739
blocks: list[str] = []
702740
visited: set[str] = set()
703741
current = raw_data
704742
labelled_by_depth = self.reply_context_max_depth > 1
705743

706744
for depth in range(self.reply_context_max_depth):
707-
parent, relation = await self._resolve_parent_note(current, depth)
745+
parent, relation = await self._resolve_parent_note(current)
708746
if not isinstance(parent, dict):
709747
break
710748

@@ -728,31 +766,31 @@ async def _build_parent_note_context(
728766
label = f"{label} - 第{depth + 1}层"
729767
blocks.append(f"[{label}]\n{summary}")
730768

731-
# depth=0 且当前是 reply:如果还有 renote(reply-with-quote),也补上
732-
if (
733-
depth == 0
734-
and relation == "被回复的原帖"
735-
and isinstance(current.get("renote"), dict)
736-
):
737-
renote_obj = current["renote"]
738-
renote_id = str(renote_obj.get("id") or "")
739-
if renote_id and renote_id not in visited:
740-
visited.add(renote_id)
741-
quote_summary = summarize_note_for_context(
742-
renote_obj,
743-
max_text_length=self.reply_context_max_text_length,
744-
)
745-
if quote_summary:
746-
quote_label = "被引用/转发的原帖"
747-
if labelled_by_depth:
748-
quote_label = f"{quote_label} - 第1层"
749-
blocks.append(f"[{quote_label}]\n{quote_summary}")
769+
# depth=0 且当前是 reply:如果还有 renote(reply-with-quote),也补上。
770+
# 走 _resolve_renote_target 而不是只检查 isinstance(current.get("renote")),
771+
# 这样 payload 仅给 renoteId 时也能通过 API 回退拉取引用帖。
772+
if depth == 0 and relation == "被回复的原帖":
773+
renote_parent = await self._resolve_renote_target(current)
774+
if isinstance(renote_parent, dict):
775+
renote_id = str(renote_parent.get("id") or "")
776+
if renote_id and renote_id not in visited:
777+
visited.add(renote_id)
778+
quote_summary = summarize_note_for_context(
779+
renote_parent,
780+
max_text_length=self.reply_context_max_text_length,
781+
)
782+
if quote_summary:
783+
quote_label = "被引用/转发的原帖"
784+
if labelled_by_depth:
785+
quote_label = f"{quote_label} - 第1层"
786+
blocks.append(f"[{quote_label}]\n{quote_summary}")
750787

751788
current = parent
752789

753790
if not blocks:
754791
return ""
755-
return "\n\n".join(blocks) + "\n---\n"
792+
# 作为 message_str 的后缀返回,前导分隔符确保与用户原文有清晰边界
793+
return "\n\n---\n" + "\n\n".join(blocks)
756794

757795
async def convert_message(self, raw_data: dict[str, Any]) -> AstrBotMessage:
758796
"""将 Misskey 贴文数据转换为 AstrBotMessage 对象"""
@@ -771,16 +809,18 @@ async def convert_message(self, raw_data: dict[str, Any]) -> AstrBotMessage:
771809
is_chat=False,
772810
)
773811

774-
# 评论区原帖上下文:作为前缀注入到消息链与 message_str
812+
# 评论区原帖上下文:拼到 message_str 尾部,避免破坏 wake_prefix / 命令
813+
# 前缀 startswith 匹配(waking_check 与 star.filter.command 都是头部匹配)。
814+
# LLM 主路径直接读 message_str(astr_main_agent / agent third_party 都遍历
815+
# message chain 时只取多模态 Comp,忽略 Comp.Plain),所以这里不再把
816+
# parent_ctx 加到 message.message —— 那会变成读不到的死代码。
775817
parent_ctx = ""
776818
if self.include_reply_context:
777819
try:
778820
parent_ctx = await self._build_parent_note_context(raw_data)
779821
except Exception as e:
780822
logger.warning(f"[Misskey] 构建父帖上下文失败: {e}")
781823
parent_ctx = ""
782-
if parent_ctx:
783-
message.message.append(Comp.Plain(parent_ctx))
784824

785825
message_parts = []
786826
raw_text = raw_data.get("text", "")
@@ -811,7 +851,7 @@ async def convert_message(self, raw_data: dict[str, Any]) -> AstrBotMessage:
811851
if message_parts
812852
else ""
813853
)
814-
message.message_str = parent_ctx + body if parent_ctx else body
854+
message.message_str = body + parent_ctx if parent_ctx else body
815855
return message
816856

817857
async def convert_chat_message(self, raw_data: dict[str, Any]) -> AstrBotMessage:

astrbot/core/platform/sources/misskey/misskey_api.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -749,17 +749,20 @@ async def get_current_user(self) -> dict[str, Any]:
749749
return await self._make_request("i", {})
750750

751751
async def get_note(self, note_id: str) -> dict[str, Any] | None:
752-
"""通过 notes/show 获取帖子详情。失败返回 None,不抛异常。
752+
"""通过 notes/show 获取帖子详情。普通失败返回 None,不抛异常。
753753
754754
私密帖 / 未联邦化的 remote 帖 / 已被删除帖会返回 403 或 404,
755755
这些是预期行为,因此降级到 debug 级日志。
756+
但 asyncio.CancelledError 必须原样抛出,否则会破坏 shutdown 与超时取消。
756757
"""
757758
if not note_id:
758759
return None
759760
try:
760761
result = await self._make_request("notes/show", {"noteId": note_id})
761762
if isinstance(result, dict):
762763
return result
764+
except asyncio.CancelledError:
765+
raise
763766
except Exception as e:
764767
logger.debug(f"[Misskey API] 获取帖子失败 ({note_id}): {e}")
765768
return None

0 commit comments

Comments
 (0)