Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions astrbot/core/config/default.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
179 changes: 178 additions & 1 deletion astrbot/core/platform/sources/misskey/misskey_adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@
process_files,
resolve_message_visibility,
serialize_message_chain,
summarize_note_for_context,
)

# Constants
Expand Down Expand Up @@ -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 = ""
Expand All @@ -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)

Expand Down Expand Up @@ -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 "")
Comment on lines +688 to +773
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.

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)
Expand All @@ -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", "")

Expand All @@ -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
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 👍 / 👎.

return message

async def convert_chat_message(self, raw_data: dict[str, Any]) -> AstrBotMessage:
Expand Down
19 changes: 19 additions & 0 deletions astrbot/core/platform/sources/misskey/misskey_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Comment thread
sourcery-ai[bot] marked this conversation as resolved.
logger.debug(f"[Misskey API] 获取帖子失败 ({note_id}): {e}")
return None

async def send_message(
self,
user_id_or_payload: Any,
Expand Down
72 changes: 72 additions & 0 deletions astrbot/core/platform/sources/misskey/misskey_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
12 changes: 12 additions & 0 deletions dashboard/src/i18n/locales/en-US/features/config-metadata.json
Original file line number Diff line number Diff line change
Expand Up @@ -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."
Expand All @@ -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."
Expand Down
Loading