diff --git a/astrbot/core/config/default.py b/astrbot/core/config/default.py index 64243a82f5..0ea2fc799a 100644 --- a/astrbot/core/config/default.py +++ b/astrbot/core/config/default.py @@ -481,6 +481,10 @@ class ChatProviderTemplate(TypedDict): "misskey_enable_file_upload": True, "misskey_upload_concurrency": 3, "misskey_upload_folder": "", + # 评论区原帖上下文注入 + "misskey_include_reply_context": True, + "misskey_reply_context_max_depth": 1, + "misskey_reply_context_max_text_length": 500, }, "Slack": { "id": "slack", @@ -747,6 +751,21 @@ class ChatProviderTemplate(TypedDict): "type": "string", "hint": "可选:填写 Misskey 网盘中目标文件夹的 ID,上传的文件将放置到该文件夹内。留空则使用账号网盘根目录。", }, + "misskey_include_reply_context": { + "description": "在评论 @ 时注入原帖上下文", + "type": "bool", + "hint": "启用后,当用户在某条帖子下评论或回复并 @机器人时,机器人将拿到被回复/被引用的原帖文本作为上下文,从而做出针对原帖的有意义回复。", + }, + "misskey_reply_context_max_depth": { + "description": "原帖追溯最大层数", + "type": "int", + "hint": "向上追溯多少层 reply/renote 链。1 表示仅取直接父帖,最大允许 5。深度越大对 Misskey API 的串行调用越多,会拉高响应延迟。", + }, + "misskey_reply_context_max_text_length": { + "description": "单层原帖正文截断长度", + "type": "int", + "hint": "每层原帖正文超过该字符数时会被截断,避免过长帖子刷爆 LLM prompt。最小 50,建议 500。填 -1 表示不限制(完整保留原文)。", + }, "card_template_id": { "description": "卡片模板 ID", "type": "string", diff --git a/astrbot/core/platform/sources/misskey/misskey_adapter.py b/astrbot/core/platform/sources/misskey/misskey_adapter.py index 1692c251c5..61ae12abf6 100644 --- a/astrbot/core/platform/sources/misskey/misskey_adapter.py +++ b/astrbot/core/platform/sources/misskey/misskey_adapter.py @@ -37,6 +37,7 @@ process_files, resolve_message_visibility, serialize_message_chain, + summarize_note_for_context, ) # Constants @@ -91,6 +92,24 @@ def __init__( except Exception: self.max_download_bytes = None + # 评论区原帖上下文注入 + self.include_reply_context = bool( + self.config.get("misskey_include_reply_context", True), + ) + try: + self.reply_context_max_depth = max( + 0, + min(int(self.config.get("misskey_reply_context_max_depth", 1)), 5), + ) + except Exception: + self.reply_context_max_depth = 1 + try: + _raw_len = int(self.config.get("misskey_reply_context_max_text_length", 500)) + # -1 表示不截断;否则强制下限 50 防止误填导致摘要几乎为空 + self.reply_context_max_text_length = -1 if _raw_len < 0 else max(50, _raw_len) + except Exception: + self.reply_context_max_text_length = 500 + self.api: MisskeyAPI | None = None self._running = False self.bot_self_id = "" @@ -110,6 +129,10 @@ def meta(self) -> PlatformMetadata: "misskey_download_timeout": 15, "misskey_download_chunk_size": 65536, "misskey_max_download_bytes": None, + # 评论区原帖上下文注入 + "misskey_include_reply_context": True, + "misskey_reply_context_max_depth": 1, + "misskey_reply_context_max_text_length": 500, } default_config.update(self.config) @@ -631,6 +654,146 @@ async def _upload_comp(comp) -> object | None: return await super().send_by_session(session, message_chain) + async def _resolve_reply_target( + self, + current: dict[str, Any], + ) -> dict[str, Any] | None: + """解析当前 note 的 reply 目标(被回复的原帖)。 + + 优先用 payload 中已展开的 `reply` 对象;缺失时通过 `replyId` + 走一次 notes/show API 回退。两者皆无返回 None。 + """ + reply_obj = current.get("reply") + if isinstance(reply_obj, dict): + return reply_obj + reply_id = current.get("replyId") + if reply_id and self.api: + fetched = await self.api.get_note(str(reply_id)) + if isinstance(fetched, dict): + return fetched + return None + + async def _resolve_renote_target( + self, + current: dict[str, Any], + ) -> dict[str, Any] | None: + """解析当前 note 的 renote 目标(被引用/转发的原帖)。 + + 优先用 payload 中已展开的 `renote` 对象;缺失时通过 `renoteId` + 走一次 notes/show API 回退。两者皆无返回 None。 + """ + renote_obj = current.get("renote") + if isinstance(renote_obj, dict): + return renote_obj + 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 + + def append_summary_block( + target: dict[str, Any], + relation: str, + depth_index: int, + ) -> None: + """生成摘要并追加到 blocks。两处调用(主父帖 / 引用帖)共用此 helper + 以避免「summarize + label + blocks.append」的重复逻辑。""" + summary = summarize_note_for_context( + target, + max_text_length=self.reply_context_max_text_length, + ) + if not summary: + return + label = relation + if labelled_by_depth: + label = f"{label} - 第{depth_index + 1}层" + blocks.append(f"[{label}]\n{summary}") + + 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 "") + if parent_uid and parent_uid == self.bot_self_id: + return "" + + append_summary_block(parent, relation or "被回复的原帖", depth) + + # depth=0 且当前是 reply:如果还有 renote(reply-with-quote),也补上。 + # 走 _resolve_renote_target 而不是只检查 isinstance(current.get("renote")), + # 这样 payload 仅给 renoteId 时也能通过 API 回退拉取引用帖。 + if depth == 0 and relation == "被回复的原帖": + renote_parent = await self._resolve_renote_target(current) + if isinstance(renote_parent, dict): + renote_id = str(renote_parent.get("id") or "") + if renote_id and renote_id not in visited: + visited.add(renote_id) + append_summary_block(renote_parent, "被引用/转发的原帖", 0) + + current = parent + + if not blocks: + return "" + # 作为 message_str 的后缀返回,前导分隔符确保与用户原文有清晰边界 + return "\n\n---\n" + "\n\n".join(blocks) + async def convert_message(self, raw_data: dict[str, Any]) -> AstrBotMessage: """将 Misskey 贴文数据转换为 AstrBotMessage 对象""" sender_info = extract_sender_info(raw_data, is_chat=False) @@ -648,6 +811,19 @@ async def convert_message(self, raw_data: dict[str, Any]) -> AstrBotMessage: is_chat=False, ) + # 评论区原帖上下文:拼到 message_str 尾部,避免破坏 wake_prefix / 命令 + # 前缀 startswith 匹配(waking_check 与 star.filter.command 都是头部匹配)。 + # LLM 主路径直接读 message_str(astr_main_agent / agent third_party 都遍历 + # message chain 时只取多模态 Comp,忽略 Comp.Plain),所以这里不再把 + # parent_ctx 加到 message.message —— 那会变成读不到的死代码。 + parent_ctx = "" + if self.include_reply_context: + try: + parent_ctx = await self._build_parent_note_context(raw_data) + except Exception as e: + logger.warning(f"[Misskey] 构建父帖上下文失败: {e}") + parent_ctx = "" + message_parts = [] raw_text = raw_data.get("text", "") @@ -672,11 +848,12 @@ async def convert_message(self, raw_data: dict[str, Any]) -> AstrBotMessage: if poll and isinstance(poll, dict): self._process_poll_data(message, poll, message_parts) - message.message_str = ( + body = ( " ".join(part for part in message_parts if part.strip()) if message_parts else "" ) + message.message_str = body + parent_ctx if parent_ctx else body return message async def convert_chat_message(self, raw_data: dict[str, Any]) -> AstrBotMessage: diff --git a/astrbot/core/platform/sources/misskey/misskey_api.py b/astrbot/core/platform/sources/misskey/misskey_api.py index 3e5eb9a90e..4dabd95168 100644 --- a/astrbot/core/platform/sources/misskey/misskey_api.py +++ b/astrbot/core/platform/sources/misskey/misskey_api.py @@ -748,6 +748,25 @@ async def get_current_user(self) -> dict[str, Any]: """获取当前用户信息""" return await self._make_request("i", {}) + async def get_note(self, note_id: str) -> dict[str, Any] | None: + """通过 notes/show 获取帖子详情。普通失败返回 None,不抛异常。 + + 私密帖 / 未联邦化的 remote 帖 / 已被删除帖会返回 403 或 404, + 这些是预期行为,因此降级到 debug 级日志。 + 但 asyncio.CancelledError 必须原样抛出,否则会破坏 shutdown 与超时取消。 + """ + if not note_id: + return None + try: + result = await self._make_request("notes/show", {"noteId": note_id}) + if isinstance(result, dict): + return result + except asyncio.CancelledError: + raise + except Exception as e: + logger.debug(f"[Misskey API] 获取帖子失败 ({note_id}): {e}") + return None + async def send_message( self, user_id_or_payload: Any, diff --git a/astrbot/core/platform/sources/misskey/misskey_utils.py b/astrbot/core/platform/sources/misskey/misskey_utils.py index 86b76c21f2..0d21f41436 100644 --- a/astrbot/core/platform/sources/misskey/misskey_utils.py +++ b/astrbot/core/platform/sources/misskey/misskey_utils.py @@ -312,6 +312,78 @@ def format_poll(poll: dict[str, Any]) -> str: return " ".join(parts) +def summarize_note_for_context( + note: dict[str, Any], + max_text_length: int = 500, +) -> str: + """将一个 Misskey 帖子对象格式化成纯文本摘要,供 LLM 阅读上下文用。 + + 设计原则: + - 只输出纯文本,不创建任何多模态组件 — 避免 LLM 把父帖图片误识别为本次输入。 + - 远端用户使用 acct 风格(@user@host)。 + - text 为空但有 CW / 附件 / 投票时,省略空内容行,避免冗余。 + - max_text_length 为负数(约定 -1)表示不截断。 + """ + if not isinstance(note, dict): + return "" + + user = note.get("user") or {} + username = user.get("username") or "" + host = user.get("host") or "" + nickname = user.get("name") or username or "未知用户" + if username: + author = f"@{username}@{host}" if host else f"@{username}" + if nickname and nickname != username: + author = f"{author} ({nickname})" + else: + author = nickname or "未知用户" + + text = note.get("text") or "" + if ( + isinstance(text, str) + and max_text_length >= 0 + and len(text) > max_text_length + ): + text = text[:max_text_length] + "...(已截断)" + + cw = note.get("cw") + files = note.get("files") or [] + poll = note.get("poll") + + lines: list[str] = [f"作者: {author}"] + if cw: + lines.append(f"内容警告(CW): {cw}") + if text: + lines.append(f"内容: {text}") + elif not cw and not files and not isinstance(poll, dict): + lines.append("内容: (无文本)") + + if files: + descs = [] + for f in files: + if not isinstance(f, dict): + continue + name = f.get("name") or "附件" + ftype = f.get("type") or "" + if ftype.startswith("image/"): + descs.append(f"图片[{name}]") + elif ftype.startswith("video/"): + descs.append(f"视频[{name}]") + elif ftype.startswith("audio/"): + descs.append(f"音频[{name}]") + else: + descs.append(f"文件[{name}]") + if descs: + lines.append("附件: " + ", ".join(descs)) + + if isinstance(poll, dict): + poll_text = format_poll(poll) + if poll_text: + lines.append(poll_text) + + return "\n".join(lines) + + def extract_sender_info( raw_data: dict[str, Any], is_chat: bool = False, diff --git a/dashboard/src/i18n/locales/en-US/features/config-metadata.json b/dashboard/src/i18n/locales/en-US/features/config-metadata.json index c0796b7f07..70c3392d50 100644 --- a/dashboard/src/i18n/locales/en-US/features/config-metadata.json +++ b/dashboard/src/i18n/locales/en-US/features/config-metadata.json @@ -504,6 +504,10 @@ "description": "Enable File Upload to Misskey", "hint": "When enabled, the adapter uploads files in message chains to Misskey. URL files try server-side upload first; if async upload fails, it falls back to local download and upload." }, + "misskey_include_reply_context": { + "description": "Inject Parent Note as Context", + "hint": "When enabled, replying to or quoting a post and @-mentioning the bot will surface the parent post's text as context, so the bot can respond with awareness of the original post." + }, "misskey_instance_url": { "description": "Misskey Instance URL", "hint": "e.g. https://misskey.example. The Misskey instance where the bot account lives." @@ -516,6 +520,14 @@ "description": "Max Download Size (bytes)", "hint": "To limit download size to prevent OOM, set the maximum bytes; empty or null means no limit." }, + "misskey_reply_context_max_depth": { + "description": "Parent Note Trace Max Depth", + "hint": "How many levels of reply/renote chain to walk up. 1 = direct parent only; max 5. Higher depth means more sequential Misskey API calls and higher latency." + }, + "misskey_reply_context_max_text_length": { + "description": "Per-Level Parent Text Max Length", + "hint": "Truncate each parent note's body to this character count to keep prompts compact. Minimum 50, recommended 500. Set to -1 for no limit (keep full text)." + }, "misskey_token": { "description": "Misskey Access Token", "hint": "API access token generated in the connection service settings." diff --git a/dashboard/src/i18n/locales/ru-RU/features/config-metadata.json b/dashboard/src/i18n/locales/ru-RU/features/config-metadata.json index 2f62db65ab..8a061cba16 100644 --- a/dashboard/src/i18n/locales/ru-RU/features/config-metadata.json +++ b/dashboard/src/i18n/locales/ru-RU/features/config-metadata.json @@ -504,6 +504,10 @@ "description": "Включить загрузку файлов в Misskey", "hint": "Бот будет загружать файлы из сообщений в хранилище Misskey." }, + "misskey_include_reply_context": { + "description": "Подставлять контекст родительского поста", + "hint": "Когда пользователь отвечает на пост или цитирует его и упоминает бота, текст родительского поста добавляется в контекст, чтобы бот мог ответить с учётом исходного поста." + }, "misskey_instance_url": { "description": "URL инстанса Misskey", "hint": "Например, https://misskey.example" @@ -516,6 +520,14 @@ "description": "Макс. размер загрузки (байт)", "hint": "Лимит на размер загружаемых файлов для предотвращения нехватки памяти." }, + "misskey_reply_context_max_depth": { + "description": "Макс. глубина трассировки родительских постов", + "hint": "На сколько уровней вверх по цепочке reply/renote подниматься. 1 = только прямой родитель; максимум 5. Большая глубина = больше последовательных запросов к API Misskey и выше задержка." + }, + "misskey_reply_context_max_text_length": { + "description": "Макс. длина текста на каждом уровне", + "hint": "Текст каждого родительского поста обрезается до этого числа символов, чтобы не раздувать prompt. Минимум 50, рекомендуется 500. Значение -1 отключает обрезку (полный текст)." + }, "misskey_token": { "description": "Токен доступа Misskey", "hint": "Токен доступа к API, созданный в настройках сервиса подключения." diff --git a/dashboard/src/i18n/locales/zh-CN/features/config-metadata.json b/dashboard/src/i18n/locales/zh-CN/features/config-metadata.json index 407e9f9f45..e008fcaab1 100644 --- a/dashboard/src/i18n/locales/zh-CN/features/config-metadata.json +++ b/dashboard/src/i18n/locales/zh-CN/features/config-metadata.json @@ -506,6 +506,10 @@ "description": "启用文件上传到 Misskey", "hint": "启用后,适配器会尝试将消息链中的文件上传到 Misskey。URL 文件会先尝试服务器端上传,异步上传失败时会回退到下载后本地上传。" }, + "misskey_include_reply_context": { + "description": "在评论 @ 时注入原帖上下文", + "hint": "启用后,当用户在某条帖子下评论或回复并 @机器人时,机器人将拿到被回复/被引用的原帖文本作为上下文,从而做出针对原帖的有意义回复。" + }, "misskey_instance_url": { "description": "Misskey 实例 URL", "hint": "例如 https://misskey.example,填写 Bot 账号所在的 Misskey 实例地址" @@ -518,6 +522,14 @@ "description": "最大允许下载字节数(超出则中止)", "hint": "如果希望限制下载文件的最大大小以防止 OOM,请填写最大字节数;留空或 null 表示不限制。" }, + "misskey_reply_context_max_depth": { + "description": "原帖追溯最大层数", + "hint": "向上追溯多少层 reply/renote 链。1 表示仅取直接父帖,最大允许 5。深度越大对 Misskey API 的串行调用越多,会拉高响应延迟。" + }, + "misskey_reply_context_max_text_length": { + "description": "单层原帖正文截断长度", + "hint": "每层原帖正文超过该字符数时会被截断,避免过长帖子刷爆 LLM prompt。最小 50,建议 500。填 -1 表示不限制(完整保留原文)。" + }, "misskey_token": { "description": "Misskey Access Token", "hint": "连接服务设置生成的 API 鉴权访问令牌(Access token)" diff --git a/scripts/smoke_misskey_parent_ctx.py b/scripts/smoke_misskey_parent_ctx.py new file mode 100644 index 0000000000..58cc5688d4 --- /dev/null +++ b/scripts/smoke_misskey_parent_ctx.py @@ -0,0 +1,333 @@ +"""Misskey 父帖上下文注入 smoke 测试(PR #7893 follow-up)。 + +覆盖场景: +1. normal reply(payload 已展开 reply 对象)→ parent_ctx 在 message_str 尾部,body 在头部 +2. 自帖跳过(reply.user.id == bot_self_id) +3. replyId-only → 通过 mock api.get_note 拉取 +4. reply + renote 已展开 → 双注入 +5. reply + renoteId(仅 ID)→ 通过 mock api.get_note 拉到引用帖,仍双注入(关键回归) +6. 关闭开关(include_reply_context=False) +7. API 失败:mock get_note 抛 RuntimeError → 优雅吞掉返回空 +8. 完全独立帖(无 reply / replyId / renote / renoteId)→ 早返回路径 +9. wake_prefix / 命令前缀兼容:body 是 "/help" 时 message_str 仍以 "/help" 开头 +""" + +from __future__ import annotations + +import asyncio +import sys +from pathlib import Path +from typing import Any + +ROOT = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(ROOT)) + +from astrbot.core.platform.sources.misskey.misskey_adapter import ( # noqa: E402 + MisskeyPlatformAdapter, +) + + +class FakeAPI: + def __init__( + self, + notes: dict[str, dict[str, Any]] | None = None, + raise_for: set[str] | None = None, + ) -> None: + self.notes = notes or {} + self.raise_for = raise_for or set() + self.calls: list[str] = [] + + async def get_note(self, note_id: str) -> dict[str, Any] | None: + self.calls.append(note_id) + if note_id in self.raise_for: + raise RuntimeError(f"mock api error for {note_id}") + return self.notes.get(note_id) + + +def make_adapter( + *, + include: bool = True, + depth: int = 1, + api: FakeAPI | None = None, + bot_self_id: str = "bot_user", + bot_username: str = "bot", + max_text_length: int = 500, +) -> MisskeyPlatformAdapter: + """绕开 __init__,直接构造一个最小可用对象用于测试 convert_message。""" + adapter = MisskeyPlatformAdapter.__new__(MisskeyPlatformAdapter) + adapter.include_reply_context = include + adapter.reply_context_max_depth = depth + adapter.reply_context_max_text_length = max_text_length + adapter.api = api + adapter.bot_self_id = bot_self_id + adapter._bot_username = bot_username + adapter._user_cache = {} + # 让 _process_poll_data 可被调用而不抛 + adapter._process_poll_data = lambda *a, **kw: None # type: ignore[method-assign] + return adapter + + +def user(uid: str, name: str = "Bob", username: str = "bob") -> dict[str, Any]: + return {"id": uid, "username": username, "name": name} + + +def note( + nid: str, + *, + text: str = "", + user_id: str = "u_other", + username: str = "bob", + name: str = "Bob", + files: list | None = None, + reply: dict | None = None, + renote: dict | None = None, + reply_id: str | None = None, + renote_id: str | None = None, +) -> dict[str, Any]: + n: dict[str, Any] = { + "id": nid, + "text": text, + "user": user(user_id, name=name, username=username), + "files": files or [], + } + if reply is not None: + n["reply"] = reply + if renote is not None: + n["renote"] = renote + if reply_id is not None: + n["replyId"] = reply_id + if renote_id is not None: + n["renoteId"] = renote_id + return n + + +# convert_message 内部依赖 extract_sender_info / create_base_message / cache_user_info / +# process_at_mention / process_files。直接构造 raw_data 跑过去。 + +results: list[tuple[str, bool, str]] = [] + + +def check(name: str, ok: bool, hint: str = "") -> None: + results.append((name, ok, hint)) + flag = "PASS" if ok else "FAIL" + print(f"[{flag}] {name}" + (f" :: {hint}" if hint and not ok else "")) + + +async def case_normal_reply() -> None: + parent = note("n0", text="原帖正文 hello", user_id="u_alice", username="alice", name="Alice") + raw = note("n1", text="@bot 这帖在说啥", user_id="u_b", username="bobby", reply=parent) + + adapter = make_adapter() + msg = await adapter.convert_message(raw) + + body_first = msg.message_str.startswith("@bot 这帖在说啥") or msg.message_str.startswith("这帖在说啥") + has_separator = "\n---\n" in msg.message_str + has_parent_block = "[被回复的原帖]" in msg.message_str + has_parent_text = "原帖正文 hello" in msg.message_str + parent_after_body = msg.message_str.find("[被回复的原帖]") > 0 # 不是 0 + + check( + "case1 normal reply: body 在头部 + parent 在尾部", + body_first and has_separator and has_parent_block and has_parent_text and parent_after_body, + f"got message_str={msg.message_str!r}", + ) + + +async def case_self_authored_parent() -> None: + parent = note("n0", text="bot 自己的帖", user_id="bot_user", username="bot", name="Bot") + raw = note("n1", text="hi", reply=parent) + + adapter = make_adapter(bot_self_id="bot_user") + msg = await adapter.convert_message(raw) + + check( + "case2 自帖跳过:parent_ctx 不应注入", + "[被回复的原帖]" not in msg.message_str and "\n---\n" not in msg.message_str, + f"got message_str={msg.message_str!r}", + ) + + +async def case_reply_id_only_fetch() -> None: + fetched_parent = note( + "n0", text="原帖通过 API 拉到", user_id="u_a", username="alice", name="Alice" + ) + api = FakeAPI(notes={"n0": fetched_parent}) + + raw = note("n1", text="hi", reply_id="n0") + adapter = make_adapter(api=api) + msg = await adapter.convert_message(raw) + + check( + "case3 replyId-only:通过 API 注入", + "原帖通过 API 拉到" in msg.message_str and "[被回复的原帖]" in msg.message_str + and api.calls == ["n0"], + f"calls={api.calls} got={msg.message_str!r}", + ) + + +async def case_reply_with_quote_expanded() -> None: + parent = note("n0", text="被回复帖", user_id="u_a", username="alice") + quoted = note("nq", text="被引用帖", user_id="u_q", username="quotee") + raw = note("n1", text="reply with quote", reply=parent, renote=quoted) + + adapter = make_adapter() + msg = await adapter.convert_message(raw) + + check( + "case4 reply+renote 都展开:双注入", + "被回复帖" in msg.message_str + and "被引用帖" in msg.message_str + and "[被回复的原帖]" in msg.message_str + and "[被引用/转发的原帖]" in msg.message_str, + f"got={msg.message_str!r}", + ) + + +async def case_reply_with_quote_id_only() -> None: + """关键回归:reply 已展开但 renote 仅给 renoteId,应通过 API 拉到引用帖。""" + parent = note("n0", text="被回复帖", user_id="u_a", username="alice") + fetched_quote = note("nq", text="API 拉到的被引用帖", user_id="u_q", username="quotee") + api = FakeAPI(notes={"nq": fetched_quote}) + + raw = note("n1", text="hi", reply=parent, renote_id="nq") + adapter = make_adapter(api=api) + msg = await adapter.convert_message(raw) + + check( + "case5 reply+renoteId(关键回归):API fallback 拉到引用帖", + "被回复帖" in msg.message_str + and "API 拉到的被引用帖" in msg.message_str + and "[被引用/转发的原帖]" in msg.message_str + and api.calls == ["nq"], + f"calls={api.calls} got={msg.message_str!r}", + ) + + +async def case_disabled() -> None: + parent = note("n0", text="原帖", user_id="u_a", username="alice") + raw = note("n1", text="hi", reply=parent) + + adapter = make_adapter(include=False) + msg = await adapter.convert_message(raw) + + check( + "case6 关闭开关:parent_ctx 完全不注入", + "[被回复的原帖]" not in msg.message_str and "\n---\n" not in msg.message_str, + f"got={msg.message_str!r}", + ) + + +async def case_api_failure() -> None: + api = FakeAPI(raise_for={"n_bad"}) + raw = note("n1", text="hi", reply_id="n_bad") + adapter = make_adapter(api=api) + msg = await adapter.convert_message(raw) + + check( + "case7 API 失败:优雅吞掉,无 parent_ctx 但 body 完整", + "hi" in msg.message_str and "[被回复的原帖]" not in msg.message_str, + f"got={msg.message_str!r}", + ) + + +async def case_standalone_no_parent() -> None: + """完全独立帖:无 reply / replyId / renote / renoteId,应走早返回路径。""" + api = FakeAPI() + raw = note("n1", text="just a standalone note") + adapter = make_adapter(api=api) + msg = await adapter.convert_message(raw) + + check( + "case8 独立帖早返回:API 不被调用 + parent_ctx 空", + api.calls == [] + and "[被回复的原帖]" not in msg.message_str + and "\n---\n" not in msg.message_str, + f"calls={api.calls} got={msg.message_str!r}", + ) + + +async def case_command_compat() -> None: + """关键回归:body 以命令开头时("/help"),尾部追加 parent_ctx 不影响命令前缀匹配。""" + parent = note("n0", text="原帖", user_id="u_a", username="alice") + raw = note("n1", text="/help", reply=parent) + adapter = make_adapter() + msg = await adapter.convert_message(raw) + + starts_with_cmd = msg.message_str.startswith("/help") + has_parent_after = "[被回复的原帖]" in msg.message_str + + check( + "case9 命令兼容:message_str 以 /help 开头 + parent 在尾部", + starts_with_cmd and has_parent_after, + f"got={msg.message_str!r}", + ) + + +async def case_get_note_cancelled_propagates() -> None: + """get_note 必须把 asyncio.CancelledError 原样向上抛,否则 shutdown 会卡住。""" + from astrbot.core.platform.sources.misskey.misskey_api import MisskeyAPI + + api = MisskeyAPI.__new__(MisskeyAPI) + + async def fake_make_request(*a: Any, **kw: Any) -> None: + raise asyncio.CancelledError() + + api._make_request = fake_make_request # type: ignore[method-assign] + + raised: Exception | None = None + try: + await api.get_note("any-id") + except asyncio.CancelledError as e: + raised = e + except Exception as e: # noqa: BLE001 + raised = e + + check( + "case10 get_note: CancelledError 原样向上抛", + isinstance(raised, asyncio.CancelledError), + f"raised={type(raised).__name__ if raised else 'None'}", + ) + + +async def case_get_note_normal_exception_swallowed() -> None: + """普通异常(403/404/网络错)应继续被吞掉返回 None,保持原行为。""" + from astrbot.core.platform.sources.misskey.misskey_api import MisskeyAPI + + api = MisskeyAPI.__new__(MisskeyAPI) + + async def fake_make_request(*a: Any, **kw: Any) -> None: + raise RuntimeError("HTTP 403 forbidden") + + api._make_request = fake_make_request # type: ignore[method-assign] + result = await api.get_note("any-id") + + check( + "case11 get_note: 普通异常仍吞掉返回 None", + result is None, + f"result={result!r}", + ) + + +async def main() -> None: + await case_normal_reply() + await case_self_authored_parent() + await case_reply_id_only_fetch() + await case_reply_with_quote_expanded() + await case_reply_with_quote_id_only() + await case_disabled() + await case_api_failure() + await case_standalone_no_parent() + await case_command_compat() + await case_get_note_cancelled_propagates() + await case_get_note_normal_exception_swallowed() + + failed = [n for n, ok, _ in results if not ok] + print() + print(f"== summary: {len(results) - len(failed)}/{len(results)} passed ==") + if failed: + print("FAILED:", failed) + sys.exit(1) + + +if __name__ == "__main__": + asyncio.run(main())