From 941c1ebe136d94051251b257e7b894cd4b68a22b Mon Sep 17 00:00:00 2001
From: Null <1708213363@qq.com>
Date: Mon, 22 Jun 2026 20:21:12 +0800
Subject: [PATCH 1/7] fix(messages): cache forward message snapshots
---
docs/configuration.md | 2 +-
docs/usage.md | 2 +-
src/Undefined/attachments/__init__.py | 6 +
src/Undefined/attachments/forward_snapshot.py | 115 +++++++++++++++++
src/Undefined/attachments/segments.py | 95 +++++++++++---
src/Undefined/handlers/message_flow.py | 1 +
.../messages/get_forward_msg/handler.py | 119 ++++++++++++++++--
src/Undefined/utils/paths.py | 1 +
tests/test_forward_snapshot.py | 92 ++++++++++++++
tests/test_get_forward_msg_tool.py | 117 ++++++++++++++++-
10 files changed, 520 insertions(+), 30 deletions(-)
create mode 100644 src/Undefined/attachments/forward_snapshot.py
create mode 100644 tests/test_forward_snapshot.py
diff --git a/docs/configuration.md b/docs/configuration.md
index bef89406..b066c1b5 100644
--- a/docs/configuration.md
+++ b/docs/configuration.md
@@ -580,7 +580,7 @@ Prompt caching 补充:
外部接收的远程图片或文件默认会先下载到附件缓存再生成 UID,避免后续 URL 失效;大文件超过阈值时,UID 仍会生成,但绑定的是 URL 引用而不是缓存文件,AI 可在上下文中看到原始 `source_ref`。如果本地缓存因总容量或时间清理被删除,但记录仍保留 URL,后续需要文件内容时会优先按 URL 回源下载。
-合并转发会复用同一注册表登记为 `forward_...` UID,并在实时 AI 输入中显示为 ``。历史记录仍保留递归展开后的文本;需要查看实时上下文里的转发内容时,AI 会调用 `messages.get_forward_msg` 按层读取,内层合并转发会继续分配新的 `forward_...` UID。
+合并转发会复用同一注册表登记为 `forward_...` UID,并在实时 AI 输入中显示为 ``。历史记录仍保留递归展开后的文本;需要查看实时上下文里的转发内容时,AI 会调用 `messages.get_forward_msg` 按层读取,内层合并转发会继续分配新的 `forward_...` UID。工具读取时会优先使用 `data/cache/forward_snapshots/` 下的本地快照,缺失时再回源 OneBot;如果协议端无法二次读取内层转发,会返回明确诊断和可见原始字段。
### 4.10.2 `[message_batcher]` 同 sender 短时消息合并
diff --git a/docs/usage.md b/docs/usage.md
index 5fc87cea..0b333b3f 100644
--- a/docs/usage.md
+++ b/docs/usage.md
@@ -184,7 +184,7 @@ Undefined 搭载了基于 ChromaDB 向量数据库的后台认知系统,无需
| `messages.send_text_file` | 将文本内容生成文件后发送 |
| `messages.send_url_file` | 下载指定 URL 的文件后发送 |
| `messages.send_group_sign` | 执行群签到操作 |
-| `messages.get_forward_msg` | 按层读取合并转发内容;支持 `` 和旧合并转发 ID,可用 `offset`/`limit` 分页查看更多 |
+| `messages.get_forward_msg` | 按层读取合并转发内容;支持 `` 和旧合并转发 ID,优先使用本地快照,缺失时回源 OneBot,可用 `offset`/`limit` 分页查看更多 |
---
diff --git a/src/Undefined/attachments/__init__.py b/src/Undefined/attachments/__init__.py
index 4f978ccf..fc038bb1 100644
--- a/src/Undefined/attachments/__init__.py
+++ b/src/Undefined/attachments/__init__.py
@@ -10,6 +10,10 @@
RegisteredMessageAttachments,
RenderedRichMessage,
)
+from Undefined.attachments.forward_snapshot import (
+ load_forward_snapshot,
+ save_forward_snapshot,
+)
from Undefined.attachments.registry import AttachmentRegistry
from Undefined.attachments.render import (
dispatch_pending_file_sends,
@@ -40,8 +44,10 @@
"attachment_refs_to_xml",
"build_attachment_scope",
"dispatch_pending_file_sends",
+ "load_forward_snapshot",
"register_message_attachments",
"render_message_with_attachments",
"render_message_with_pic_placeholders",
+ "save_forward_snapshot",
"scope_from_context",
]
diff --git a/src/Undefined/attachments/forward_snapshot.py b/src/Undefined/attachments/forward_snapshot.py
new file mode 100644
index 00000000..a9aab0d8
--- /dev/null
+++ b/src/Undefined/attachments/forward_snapshot.py
@@ -0,0 +1,115 @@
+"""合并转发节点快照缓存。
+
+OneBot 协议端可能只允许在收到外层合并转发时读取内层内容;之后再用
+内层 ID 调 ``get_forward_msg`` 可能返回空。这里按会话作用域保存已见节点,
+供 ``messages.get_forward_msg`` 在协议端不可回源时回退。
+"""
+
+from __future__ import annotations
+
+from datetime import datetime
+import hashlib
+from pathlib import Path
+from typing import Any, Mapping
+
+from Undefined.utils import io
+from Undefined.utils.paths import FORWARD_SNAPSHOT_CACHE_DIR
+
+
+def _snapshot_key(scope_key: str, forward_id: str) -> str:
+ payload = f"{scope_key}\n{forward_id}".encode("utf-8")
+ return hashlib.sha256(payload).hexdigest()
+
+
+def _snapshot_path(scope_key: str, forward_id: str) -> Path:
+ return FORWARD_SNAPSHOT_CACHE_DIR / f"{_snapshot_key(scope_key, forward_id)}.json"
+
+
+def _clean_json_value(value: Any) -> Any:
+ if value is None or isinstance(value, (str, int, float, bool)):
+ return value
+ if isinstance(value, Mapping):
+ cleaned: dict[str, Any] = {}
+ for raw_key, raw_value in value.items():
+ key = str(raw_key).strip()
+ if not key:
+ continue
+ cleaned[key] = _clean_json_value(raw_value)
+ return cleaned
+ if isinstance(value, (list, tuple)):
+ return [_clean_json_value(item) for item in value]
+ return str(value)
+
+
+def normalize_forward_nodes_for_snapshot(nodes: Any) -> list[dict[str, Any]]:
+ """把 OneBot 返回的合并转发节点清洗为可持久化列表。"""
+ if isinstance(nodes, Mapping):
+ messages = nodes.get("messages")
+ raw_nodes = messages if isinstance(messages, list) else []
+ elif isinstance(nodes, list):
+ raw_nodes = nodes
+ else:
+ raw_nodes = []
+
+ cleaned_nodes: list[dict[str, Any]] = []
+ for node in raw_nodes:
+ if not isinstance(node, Mapping):
+ continue
+ cleaned = _clean_json_value(node)
+ if isinstance(cleaned, dict):
+ cleaned_nodes.append(cleaned)
+ return cleaned_nodes
+
+
+async def save_forward_snapshot(
+ *,
+ scope_key: str,
+ forward_id: str,
+ nodes: Any,
+) -> bool:
+ """保存合并转发节点快照;无有效节点时不写入。"""
+ normalized_scope = str(scope_key or "").strip()
+ normalized_forward_id = str(forward_id or "").strip()
+ if not normalized_scope or not normalized_forward_id:
+ return False
+
+ normalized_nodes = normalize_forward_nodes_for_snapshot(nodes)
+ if not normalized_nodes:
+ return False
+
+ payload = {
+ "scope_key": normalized_scope,
+ "forward_id": normalized_forward_id,
+ "created_at": datetime.now().isoformat(timespec="seconds"),
+ "nodes": normalized_nodes,
+ }
+ await io.write_json(
+ _snapshot_path(normalized_scope, normalized_forward_id),
+ payload,
+ use_lock=True,
+ )
+ return True
+
+
+async def load_forward_snapshot(
+ *,
+ scope_key: str,
+ forward_id: str,
+) -> list[dict[str, Any]]:
+ """读取合并转发节点快照;不存在或格式不符时返回空列表。"""
+ normalized_scope = str(scope_key or "").strip()
+ normalized_forward_id = str(forward_id or "").strip()
+ if not normalized_scope or not normalized_forward_id:
+ return []
+
+ raw = await io.read_json(
+ _snapshot_path(normalized_scope, normalized_forward_id),
+ use_lock=False,
+ )
+ if not isinstance(raw, Mapping):
+ return []
+ if str(raw.get("scope_key", "") or "") != normalized_scope:
+ return []
+ if str(raw.get("forward_id", "") or "") != normalized_forward_id:
+ return []
+ return normalize_forward_nodes_for_snapshot(raw.get("nodes"))
diff --git a/src/Undefined/attachments/segments.py b/src/Undefined/attachments/segments.py
index f469fc33..929348f8 100644
--- a/src/Undefined/attachments/segments.py
+++ b/src/Undefined/attachments/segments.py
@@ -16,6 +16,7 @@
import httpx
+from Undefined.attachments.forward_snapshot import save_forward_snapshot
from Undefined.attachments.models import RegisteredMessageAttachments
from Undefined.utils.paths import WEBUI_FILE_CACHE_DIR
from Undefined.utils.xml import escape_xml_attr
@@ -362,6 +363,8 @@ async def register_message_attachments(
| None = None,
register_forward_refs: bool = False,
expand_forward_attachments: bool = True,
+ snapshot_forward_messages: bool = False,
+ snapshot_nested_forward_messages: bool = False,
) -> RegisteredMessageAttachments:
"""扫描消息段并将图片/文件注册到 ``AttachmentRegistry``。
@@ -373,6 +376,8 @@ async def register_message_attachments(
get_forward_messages: 可选,拉取合并转发子消息。
register_forward_refs: 是否将顶层合并转发注册为 ``forward_`` 引用。
expand_forward_attachments: 是否递归扫描合并转发内的附件。
+ snapshot_forward_messages: 是否读取当前层合并转发并缓存内层节点快照。
+ snapshot_nested_forward_messages: 缓存当前层快照后,是否继续缓存直接内层转发。
Returns:
已注册附件引用与归一化纯文本。
@@ -393,6 +398,49 @@ async def register_message_attachments(
visited_forward_ids: set[str] = set()
+ async def _fetch_forward_nodes(forward_id: str) -> list[Mapping[str, Any]]:
+ if get_forward_messages is None:
+ return []
+ try:
+ return _normalize_forward_nodes(await get_forward_messages(forward_id))
+ except Exception as exc:
+ logger.debug(
+ "[AttachmentRegistry] forward resolver failed: id=%s err=%s",
+ forward_id,
+ exc,
+ )
+ return []
+
+ async def _snapshot_direct_nested_forwards(
+ nodes: Sequence[Mapping[str, Any]],
+ ) -> None:
+ if not snapshot_nested_forward_messages:
+ return
+ for node in nodes:
+ raw_message = (
+ node.get("content") or node.get("message") or node.get("raw_message")
+ )
+ nested_segments = normalize_message_segments(raw_message)
+ for nested_segment in nested_segments:
+ nested_type = str(nested_segment.get("type", "") or "").strip().lower()
+ if nested_type != "forward":
+ continue
+ raw_nested_data = nested_segment.get("data", {})
+ nested_data = (
+ raw_nested_data if isinstance(raw_nested_data, Mapping) else {}
+ )
+ nested_forward_id = _extract_forward_id(nested_data)
+ if not nested_forward_id or nested_forward_id in visited_forward_ids:
+ continue
+ visited_forward_ids.add(nested_forward_id)
+ nested_nodes = await _fetch_forward_nodes(nested_forward_id)
+ if nested_nodes:
+ await save_forward_snapshot(
+ scope_key=scope_key,
+ forward_id=nested_forward_id,
+ nodes=nested_nodes,
+ )
+
async def _collect_from_segments(
current_segments: Sequence[Mapping[str, Any]],
*,
@@ -543,6 +591,7 @@ async def _collect_from_segments(
elif type_ == "forward":
# 合并转发递归展开,深度上限防止无限嵌套
forward_id = _extract_forward_id(data)
+ forward_nodes: list[Mapping[str, Any]] = []
if register_forward_refs and depth == 0 and forward_id:
register_forward = getattr(
registry,
@@ -559,26 +608,44 @@ async def _collect_from_segments(
)
ref = record.prompt_ref()
+ should_fetch_forward = (
+ get_forward_messages is not None
+ and forward_id
+ and forward_id not in visited_forward_ids
+ and (
+ snapshot_forward_messages
+ or (
+ expand_forward_attachments
+ and depth < _FORWARD_ATTACHMENT_MAX_DEPTH
+ )
+ )
+ )
+ if should_fetch_forward:
+ visited_forward_ids.add(forward_id)
+ forward_nodes = await _fetch_forward_nodes(forward_id)
+ if snapshot_forward_messages and forward_nodes:
+ await save_forward_snapshot(
+ scope_key=scope_key,
+ forward_id=forward_id,
+ nodes=forward_nodes,
+ )
+ await _snapshot_direct_nested_forwards(forward_nodes)
+
if (
expand_forward_attachments
and get_forward_messages is not None
and depth < _FORWARD_ATTACHMENT_MAX_DEPTH
and forward_id
- and forward_id not in visited_forward_ids
):
- visited_forward_ids.add(forward_id)
- try:
- nodes = _normalize_forward_nodes(
- await get_forward_messages(forward_id)
- )
- except Exception as exc:
- logger.debug(
- "[AttachmentRegistry] forward resolver failed: id=%s err=%s",
- forward_id,
- exc,
- )
- nodes = []
- for node_index, node in enumerate(nodes):
+ if not forward_nodes:
+ if forward_id in visited_forward_ids:
+ forward_nodes = []
+ else:
+ visited_forward_ids.add(forward_id)
+ forward_nodes = await _fetch_forward_nodes(forward_id)
+ if not forward_nodes:
+ continue
+ for node_index, node in enumerate(forward_nodes):
raw_message = (
node.get("content")
or node.get("message")
diff --git a/src/Undefined/handlers/message_flow.py b/src/Undefined/handlers/message_flow.py
index 7cc0961d..3993dd14 100644
--- a/src/Undefined/handlers/message_flow.py
+++ b/src/Undefined/handlers/message_flow.py
@@ -299,6 +299,7 @@ async def _collect_message_attachments(
else None,
register_forward_refs=True,
expand_forward_attachments=False,
+ snapshot_forward_messages=True,
)
attachments = result.attachments
# 命中表情库时为 AI 上下文补充 [表情包] 描述
diff --git a/src/Undefined/skills/toolsets/messages/get_forward_msg/handler.py b/src/Undefined/skills/toolsets/messages/get_forward_msg/handler.py
index 29ad4a69..a59d0ec7 100644
--- a/src/Undefined/skills/toolsets/messages/get_forward_msg/handler.py
+++ b/src/Undefined/skills/toolsets/messages/get_forward_msg/handler.py
@@ -5,6 +5,10 @@
from typing import Any, Mapping
from Undefined.attachments import build_attachment_scope, register_message_attachments
+from Undefined.attachments.forward_snapshot import (
+ load_forward_snapshot,
+ save_forward_snapshot,
+)
from Undefined.attachments.segments import (
forward_ref_to_tag,
normalize_message_segments,
@@ -42,13 +46,13 @@ def _format_time(raw_time: Any) -> str:
return str(raw_time)
-def _normalize_nodes(raw_nodes: Any) -> list[Mapping[str, Any]]:
+def _normalize_nodes(raw_nodes: Any) -> list[dict[str, Any]]:
if isinstance(raw_nodes, list):
- return [node for node in raw_nodes if isinstance(node, Mapping)]
+ return [dict(node) for node in raw_nodes if isinstance(node, Mapping)]
if isinstance(raw_nodes, Mapping):
messages = raw_nodes.get("messages")
if isinstance(messages, list):
- return [node for node in messages if isinstance(node, Mapping)]
+ return [dict(node) for node in messages if isinstance(node, Mapping)]
return []
@@ -86,6 +90,50 @@ def _raw_forward_id_from_record(uid_or_id: str, context: Mapping[str, Any]) -> s
return str(getattr(record, "source_ref", "") or "").strip()
+def _resolve_forward_record(
+ uid_or_id: str,
+ context: Mapping[str, Any],
+) -> tuple[str, str | None, Any | None]:
+ """解析工具入参对应的 raw forward id、scope 和注册记录。"""
+ if not uid_or_id.startswith("forward_"):
+ return uid_or_id, _resolve_scope_key(context), None
+
+ registry = context.get("attachment_registry")
+ scope_key = _resolve_scope_key(context)
+ if registry is None or not scope_key:
+ return "", scope_key, None
+ resolve = getattr(registry, "resolve", None)
+ if not callable(resolve):
+ return "", scope_key, None
+ record = resolve(uid_or_id, scope_key)
+ if record is None or getattr(record, "media_type", "") != "forward":
+ return "", scope_key, None
+ return str(getattr(record, "source_ref", "") or "").strip(), scope_key, record
+
+
+def _format_unavailable_message(
+ *,
+ message_id: str,
+ raw_forward_id: str,
+ record: Any | None,
+) -> str:
+ lines = [
+ "未能获取到合并转发消息的内容或内容为空。",
+ "这通常表示协议端当前无法回源该层合并转发,常见原因包括内层转发不可二次读取、资源过期或权限受限。",
+ f"请求 ID: {message_id}",
+ f"源 ID: {raw_forward_id}",
+ ]
+ if record is not None:
+ segment_data = getattr(record, "segment_data", {}) or {}
+ if isinstance(segment_data, Mapping) and segment_data:
+ details = ", ".join(
+ f"{key}={value}" for key, value in sorted(segment_data.items())
+ )
+ if details:
+ lines.append(f"原始字段: {details}")
+ return "\n".join(lines)
+
+
async def _register_node_segments(
*,
segments: list[Mapping[str, Any]],
@@ -130,9 +178,11 @@ async def _register_node_segments(
segments=segments,
scope_key=scope_key,
resolve_image_url=resolve_image_url,
- get_forward_messages=None,
+ get_forward_messages=context.get("get_forward_msg_callback"),
register_forward_refs=True,
expand_forward_attachments=False,
+ snapshot_forward_messages=True,
+ snapshot_nested_forward_messages=True,
)
refs = list(result.attachments) + list(result.forward_refs)
return result.normalized_text, refs
@@ -147,7 +197,7 @@ async def execute(args: dict[str, Any], context: dict[str, Any]) -> str:
if not callable(get_forward_msg_callback):
return "错误:获取合并转发消息的回调未设置"
- raw_forward_id = _raw_forward_id_from_record(message_id, context)
+ raw_forward_id, scope_key, record = _resolve_forward_record(message_id, context)
if not raw_forward_id:
return f"错误:合并转发 UID 不可用或不属于当前会话:{message_id}"
@@ -156,14 +206,59 @@ async def execute(args: dict[str, Any], context: dict[str, Any]) -> str:
# 保留参数用于向后兼容和未来扩展;当前实现默认首层,不递归展开。
_ = _safe_int(args.get("max_depth"), 1, minimum=1, maximum=5)
- try:
- nodes = _normalize_nodes(await get_forward_msg_callback(raw_forward_id))
- except Exception as exc:
- logger.exception("获取合并转发消息失败: id=%s", raw_forward_id)
- return f"获取合并转发消息失败:{exc}"
+ nodes: list[dict[str, Any]] = []
+ source_note = ""
+ if scope_key:
+ try:
+ load_nodes = await load_forward_snapshot(
+ scope_key=scope_key,
+ forward_id=raw_forward_id,
+ )
+ if load_nodes:
+ nodes = load_nodes
+ source_note = "(来自本地快照)"
+ except Exception:
+ logger.debug("读取合并转发快照失败: id=%s", raw_forward_id, exc_info=True)
+
+ if not nodes:
+ try:
+ nodes = _normalize_nodes(await get_forward_msg_callback(raw_forward_id))
+ if nodes and scope_key:
+ try:
+ await save_forward_snapshot(
+ scope_key=scope_key,
+ forward_id=raw_forward_id,
+ nodes=nodes,
+ )
+ except Exception:
+ logger.debug(
+ "保存合并转发快照失败: id=%s", raw_forward_id, exc_info=True
+ )
+ except Exception as exc:
+ logger.exception("获取合并转发消息失败: id=%s", raw_forward_id)
+ if scope_key:
+ try:
+ nodes = await load_forward_snapshot(
+ scope_key=scope_key,
+ forward_id=raw_forward_id,
+ )
+ if nodes:
+ source_note = "(来自本地快照,OneBot 回源失败)"
+ except Exception:
+ logger.debug(
+ "OneBot 失败后读取合并转发快照也失败: id=%s",
+ raw_forward_id,
+ exc_info=True,
+ )
+ if not nodes:
+ return f"获取合并转发消息失败:{exc}"
if not nodes:
- return "未能获取到合并转发消息的内容或内容为空"
+ return _format_unavailable_message(
+ message_id=message_id,
+ raw_forward_id=raw_forward_id,
+ record=record,
+ )
window = nodes[offset : offset + limit]
if not window:
@@ -232,7 +327,7 @@ async def execute(args: dict[str, Any], context: dict[str, Any]) -> str:
next_offset = offset + len(window)
page_note = (
- f"合并转发 {message_id}(源 ID: {raw_forward_id})节点 "
+ f"合并转发 {message_id}(源 ID: {raw_forward_id}){source_note}节点 "
f"{offset + 1}-{next_offset}/{len(nodes)}"
)
if next_offset < len(nodes):
diff --git a/src/Undefined/utils/paths.py b/src/Undefined/utils/paths.py
index a077bed3..330672d9 100644
--- a/src/Undefined/utils/paths.py
+++ b/src/Undefined/utils/paths.py
@@ -10,6 +10,7 @@
RENDER_CACHE_DIR: Path = CACHE_DIR / "render"
IMAGE_CACHE_DIR: Path = CACHE_DIR / "images"
ATTACHMENT_CACHE_DIR: Path = CACHE_DIR / "attachments"
+FORWARD_SNAPSHOT_CACHE_DIR: Path = CACHE_DIR / "forward_snapshots"
DOWNLOAD_CACHE_DIR: Path = CACHE_DIR / "downloads"
TEXT_FILE_CACHE_DIR: Path = CACHE_DIR / "text_files"
URL_FILE_CACHE_DIR: Path = CACHE_DIR / "url_files"
diff --git a/tests/test_forward_snapshot.py b/tests/test_forward_snapshot.py
new file mode 100644
index 00000000..86fc2d45
--- /dev/null
+++ b/tests/test_forward_snapshot.py
@@ -0,0 +1,92 @@
+from __future__ import annotations
+
+from pathlib import Path
+from typing import Any
+
+import pytest
+
+from Undefined.attachments import forward_snapshot
+from Undefined.attachments.forward_snapshot import (
+ load_forward_snapshot,
+ normalize_forward_nodes_for_snapshot,
+ save_forward_snapshot,
+)
+
+
+class _OddValue:
+ def __str__(self) -> str:
+ return "odd-value"
+
+
+@pytest.mark.asyncio
+async def test_forward_snapshot_round_trip(
+ tmp_path: Path,
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ monkeypatch.setattr(
+ forward_snapshot,
+ "FORWARD_SNAPSHOT_CACHE_DIR",
+ tmp_path / "forward_snapshots",
+ )
+
+ saved = await save_forward_snapshot(
+ scope_key="group:10001",
+ forward_id="raw-forward",
+ nodes=[
+ {
+ "sender": {"nickname": "Alice", "user_id": 123},
+ "message": [{"type": "text", "data": {"text": "hello"}}],
+ }
+ ],
+ )
+
+ assert saved is True
+ loaded = await load_forward_snapshot(
+ scope_key="group:10001",
+ forward_id="raw-forward",
+ )
+ assert loaded == [
+ {
+ "sender": {"nickname": "Alice", "user_id": 123},
+ "message": [{"type": "text", "data": {"text": "hello"}}],
+ }
+ ]
+
+
+@pytest.mark.asyncio
+async def test_forward_snapshot_is_scoped(
+ tmp_path: Path,
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ monkeypatch.setattr(
+ forward_snapshot,
+ "FORWARD_SNAPSHOT_CACHE_DIR",
+ tmp_path / "forward_snapshots",
+ )
+
+ await save_forward_snapshot(
+ scope_key="group:10001",
+ forward_id="raw-forward",
+ nodes=[{"message": [{"type": "text", "data": {"text": "group"}}]}],
+ )
+
+ assert (
+ await load_forward_snapshot(scope_key="group:20002", forward_id="raw-forward")
+ == []
+ )
+
+
+def test_normalize_forward_nodes_for_snapshot_cleans_values() -> None:
+ nodes: list[dict[str, Any]] = [
+ {
+ "message": [{"type": "text", "data": {"text": _OddValue()}}],
+ "ignored": object(),
+ }
+ ]
+
+ assert normalize_forward_nodes_for_snapshot(nodes) == [
+ {
+ "message": [{"type": "text", "data": {"text": "odd-value"}}],
+ "ignored": str(nodes[0]["ignored"]),
+ }
+ ]
diff --git a/tests/test_get_forward_msg_tool.py b/tests/test_get_forward_msg_tool.py
index 91c1b5ff..1d1b3817 100644
--- a/tests/test_get_forward_msg_tool.py
+++ b/tests/test_get_forward_msg_tool.py
@@ -6,6 +6,7 @@
import pytest
+from Undefined.attachments import forward_snapshot
from Undefined.attachments import AttachmentRegistry
from Undefined.skills.toolsets.messages.get_forward_msg.handler import execute
@@ -23,7 +24,13 @@
@pytest.mark.asyncio
async def test_get_forward_msg_accepts_forward_uid_and_registers_nested_refs(
tmp_path: Path,
+ monkeypatch: pytest.MonkeyPatch,
) -> None:
+ monkeypatch.setattr(
+ forward_snapshot,
+ "FORWARD_SNAPSHOT_CACHE_DIR",
+ tmp_path / "forward_snapshots",
+ )
registry = AttachmentRegistry(
registry_path=tmp_path / "attachment_registry.json",
cache_dir=tmp_path / "attachments",
@@ -59,12 +66,11 @@ async def _get_forward(forward_id: str) -> list[dict[str, Any]]:
},
)
- assert seen_ids == ["raw-forward-1"]
+ assert seen_ids == ["raw-forward-1", "raw-forward-2"]
assert "节点 1-1/1" in result
assert "第一层" in result
assert ' list[dict[str, Any]]:
assert "n1" in result
assert "n0" not in result
assert "offset=2" in result
+
+
+@pytest.mark.asyncio
+async def test_get_forward_msg_uses_snapshot_when_nested_forward_becomes_unavailable(
+ tmp_path: Path,
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ monkeypatch.setattr(
+ forward_snapshot,
+ "FORWARD_SNAPSHOT_CACHE_DIR",
+ tmp_path / "forward_snapshots",
+ )
+ registry = AttachmentRegistry(
+ registry_path=tmp_path / "attachment_registry.json",
+ cache_dir=tmp_path / "attachments",
+ )
+ outer_record = await registry.register_forward_reference(
+ "group:10001",
+ "outer-forward",
+ )
+ seen_ids: list[str] = []
+
+ async def _get_forward(forward_id: str) -> list[dict[str, Any]]:
+ seen_ids.append(forward_id)
+ if forward_id == "outer-forward":
+ return [
+ {
+ "sender": {"nickname": "Alice", "user_id": 123},
+ "message": [
+ {"type": "text", "data": {"text": "外层"}},
+ {"type": "forward", "data": {"id": "nested-forward"}},
+ ],
+ }
+ ]
+ if forward_id == "nested-forward" and seen_ids.count("nested-forward") == 1:
+ return [
+ {
+ "sender": {"nickname": "Bob", "user_id": 456},
+ "message": [
+ {"type": "text", "data": {"text": "内层内容"}},
+ ],
+ }
+ ]
+ return []
+
+ context = {
+ "attachment_registry": registry,
+ "get_forward_msg_callback": _get_forward,
+ "group_id": 10001,
+ "request_type": "group",
+ }
+
+ outer_result = await execute({"message_id": outer_record.uid}, context)
+
+ assert "外层" in outer_result
+ assert "nested-forward" in seen_ids
+ nested_records = [
+ record
+ for record in registry._records.values()
+ if record.media_type == "forward" and record.source_ref == "nested-forward"
+ ]
+ assert len(nested_records) == 1
+
+ nested_result = await execute({"message_id": nested_records[0].uid}, context)
+
+ assert "来自本地快照" in nested_result
+ assert "内层内容" in nested_result
+ assert seen_ids.count("nested-forward") == 1
+
+
+@pytest.mark.asyncio
+async def test_get_forward_msg_reports_unavailable_forward_metadata(
+ tmp_path: Path,
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ monkeypatch.setattr(
+ forward_snapshot,
+ "FORWARD_SNAPSHOT_CACHE_DIR",
+ tmp_path / "forward_snapshots",
+ )
+ registry = AttachmentRegistry(
+ registry_path=tmp_path / "attachment_registry.json",
+ cache_dir=tmp_path / "attachments",
+ )
+ record = await registry.register_forward_reference(
+ "group:10001",
+ "nested-forward",
+ segment_data={"id": "nested-forward", "resid": "resid-forward"},
+ )
+
+ async def _get_forward(_forward_id: str) -> list[dict[str, Any]]:
+ return []
+
+ result = await execute(
+ {"message_id": record.uid},
+ {
+ "attachment_registry": registry,
+ "get_forward_msg_callback": _get_forward,
+ "group_id": 10001,
+ "request_type": "group",
+ },
+ )
+
+ assert "协议端当前无法回源该层合并转发" in result
+ assert "源 ID: nested-forward" in result
+ assert "原始字段:" in result
+ assert "resid=resid-forward" in result
From 6458fdfe9aeb3b3aee32f2c95b165b1cb7ac6e8e Mon Sep 17 00:00:00 2001
From: Null <1708213363@qq.com>
Date: Mon, 22 Jun 2026 22:30:30 +0800
Subject: [PATCH 2/7] fix(messages): snapshot forward trees on receipt
Co-authored-by: GPT-5 Codex
---
docs/configuration.md | 2 +-
docs/pipelines.md | 4 +-
docs/usage.md | 2 +-
src/Undefined/ai/prompts/current_input.py | 8 +-
src/Undefined/attachments/__init__.py | 2 +
src/Undefined/attachments/forward_snapshot.py | 156 +++++++++++++++++-
src/Undefined/attachments/segments.py | 63 +++----
.../messages/get_forward_msg/handler.py | 18 +-
tests/test_attachments.py | 66 ++++++++
tests/test_forward_snapshot.py | 110 ++++++++++++
tests/test_get_forward_msg_tool.py | 6 +
tests/test_prompt_builder_cognitive_query.py | 21 +++
12 files changed, 408 insertions(+), 50 deletions(-)
diff --git a/docs/configuration.md b/docs/configuration.md
index b066c1b5..dd2a92fb 100644
--- a/docs/configuration.md
+++ b/docs/configuration.md
@@ -580,7 +580,7 @@ Prompt caching 补充:
外部接收的远程图片或文件默认会先下载到附件缓存再生成 UID,避免后续 URL 失效;大文件超过阈值时,UID 仍会生成,但绑定的是 URL 引用而不是缓存文件,AI 可在上下文中看到原始 `source_ref`。如果本地缓存因总容量或时间清理被删除,但记录仍保留 URL,后续需要文件内容时会优先按 URL 回源下载。
-合并转发会复用同一注册表登记为 `forward_...` UID,并在实时 AI 输入中显示为 ``。历史记录仍保留递归展开后的文本;需要查看实时上下文里的转发内容时,AI 会调用 `messages.get_forward_msg` 按层读取,内层合并转发会继续分配新的 `forward_...` UID。工具读取时会优先使用 `data/cache/forward_snapshots/` 下的本地快照,缺失时再回源 OneBot;如果协议端无法二次读取内层转发,会返回明确诊断和可见原始字段。
+合并转发会复用同一注册表登记为 `forward_...` UID,并在实时 AI 输入中显示为 ``。收到合并转发时会在预处理阶段递归保存当前可访问的转发树到 `data/cache/forward_snapshots/`,后续 `messages.get_forward_msg` 读取时优先使用本地快照;缺失时才回源 OneBot 并补写快照。历史记录仍保留递归展开后的文本,但同一轮 prompt 会按 `message_id` 剔除当前消息的历史副本,因此实时上下文只保留 UID;需要查看第一层或内层内容时,AI 会调用工具按层读取,内层合并转发会继续分配新的 `forward_...` UID。如果协议端无法二次读取内层转发,会返回明确诊断和可见原始字段。
### 4.10.2 `[message_batcher]` 同 sender 短时消息合并
diff --git a/docs/pipelines.md b/docs/pipelines.md
index 386f3272..399b3c99 100644
--- a/docs/pipelines.md
+++ b/docs/pipelines.md
@@ -6,8 +6,8 @@
## 运行顺序
-1. `MessageHandler` 先并行执行消息预处理:附件收集、历史文本解析、昵称或群信息读取等。图片、文件等媒体会登记为附件 UID,并在 AI 可见正文中统一写作 ``;合并转发会登记为 ``,不在实时 AI 输入中自动展开。
-2. 用户消息先写入历史。历史记录仍会递归展开合并转发文本,保持历史检索和旧行为兼容;实时 AI 输入只保留 forward UID,AI 需要查看时调用 `messages.get_forward_msg` 按层读取。
+1. `MessageHandler` 先并行执行消息预处理:附件收集、历史文本解析、昵称或群信息读取等。图片、文件等媒体会登记为附件 UID,并在 AI 可见正文中统一写作 ``;合并转发会登记为 ``,同时递归保存当前可访问的转发树快照,不在实时 AI 输入中自动展开。
+2. 用户消息先写入历史。历史记录仍会递归展开合并转发文本,保持历史检索和旧行为兼容;同一轮 prompt 会剔除当前消息的历史副本,实时 AI 输入只保留 forward UID,AI 需要查看第一层或内层内容时调用 `messages.get_forward_msg` 按层读取。
3. 若消息命中斜杠命令,立即分发命令并结束本轮后续流程;命令输入和命令输出会写入历史,供后续 AI 轮次读取。
4. 未命中命令时,`PipelineRegistry` 并行调用所有已注册管线的 `detect(context)`。
5. 对所有命中的管线,并行调用对应的 `process(detection, context)`。
diff --git a/docs/usage.md b/docs/usage.md
index 0b333b3f..3df41c5b 100644
--- a/docs/usage.md
+++ b/docs/usage.md
@@ -184,7 +184,7 @@ Undefined 搭载了基于 ChromaDB 向量数据库的后台认知系统,无需
| `messages.send_text_file` | 将文本内容生成文件后发送 |
| `messages.send_url_file` | 下载指定 URL 的文件后发送 |
| `messages.send_group_sign` | 执行群签到操作 |
-| `messages.get_forward_msg` | 按层读取合并转发内容;支持 `` 和旧合并转发 ID,优先使用本地快照,缺失时回源 OneBot,可用 `offset`/`limit` 分页查看更多 |
+| `messages.get_forward_msg` | 按层读取合并转发内容;支持 `` 和旧合并转发 ID,优先使用收到消息时递归保存的本地快照,缺失时回源 OneBot,可用 `offset`/`limit` 分页查看更多 |
---
diff --git a/src/Undefined/ai/prompts/current_input.py b/src/Undefined/ai/prompts/current_input.py
index 39770e45..b43010fb 100644
--- a/src/Undefined/ai/prompts/current_input.py
+++ b/src/Undefined/ai/prompts/current_input.py
@@ -90,15 +90,15 @@ def build_current_input_per_message_query_texts(
def _history_msg_matches_signature(
msg: dict[str, Any], signature: CurrentMessageSignature
) -> bool:
+ history_message_id = str(msg.get("message_id", "") or "").strip()
+ if signature.message_id and history_message_id:
+ return history_message_id == signature.message_id
+
sig_sender_id = signature.sender_id.strip()
sig_content = signature.content.strip()
if not sig_sender_id or not sig_content:
return False
- history_message_id = str(msg.get("message_id", "") or "").strip()
- if signature.message_id and history_message_id:
- return history_message_id == signature.message_id
-
last_sender_id = str(msg.get("user_id", "") or "").strip()
last_content = str(msg.get("message", "") or "").strip()
if last_sender_id != sig_sender_id or last_content != sig_content:
diff --git a/src/Undefined/attachments/__init__.py b/src/Undefined/attachments/__init__.py
index fc038bb1..57404444 100644
--- a/src/Undefined/attachments/__init__.py
+++ b/src/Undefined/attachments/__init__.py
@@ -13,6 +13,7 @@
from Undefined.attachments.forward_snapshot import (
load_forward_snapshot,
save_forward_snapshot,
+ snapshot_forward_tree,
)
from Undefined.attachments.registry import AttachmentRegistry
from Undefined.attachments.render import (
@@ -50,4 +51,5 @@
"render_message_with_pic_placeholders",
"save_forward_snapshot",
"scope_from_context",
+ "snapshot_forward_tree",
]
diff --git a/src/Undefined/attachments/forward_snapshot.py b/src/Undefined/attachments/forward_snapshot.py
index a9aab0d8..4b39f0ac 100644
--- a/src/Undefined/attachments/forward_snapshot.py
+++ b/src/Undefined/attachments/forward_snapshot.py
@@ -7,14 +7,23 @@
from __future__ import annotations
+import asyncio
from datetime import datetime
import hashlib
+import logging
from pathlib import Path
-from typing import Any, Mapping
+from typing import Any, Awaitable, Callable, Mapping
from Undefined.utils import io
from Undefined.utils.paths import FORWARD_SNAPSHOT_CACHE_DIR
+logger = logging.getLogger(__name__)
+
+_MAX_RECURSIVE_SNAPSHOT_DEPTH = 3
+_MAX_RECURSIVE_SNAPSHOT_NODES = 50
+_snapshot_locks: dict[str, asyncio.Lock] = {}
+_snapshot_inflight: dict[str, asyncio.Task[None]] = {}
+
def _snapshot_key(scope_key: str, forward_id: str) -> str:
payload = f"{scope_key}\n{forward_id}".encode("utf-8")
@@ -25,6 +34,15 @@ def _snapshot_path(scope_key: str, forward_id: str) -> Path:
return FORWARD_SNAPSHOT_CACHE_DIR / f"{_snapshot_key(scope_key, forward_id)}.json"
+def _snapshot_lock(scope_key: str, forward_id: str) -> asyncio.Lock:
+ key = _snapshot_key(scope_key, forward_id)
+ lock = _snapshot_locks.get(key)
+ if lock is None:
+ lock = asyncio.Lock()
+ _snapshot_locks[key] = lock
+ return lock
+
+
def _clean_json_value(value: Any) -> Any:
if value is None or isinstance(value, (str, int, float, bool)):
return value
@@ -113,3 +131,139 @@ async def load_forward_snapshot(
if str(raw.get("forward_id", "") or "") != normalized_forward_id:
return []
return normalize_forward_nodes_for_snapshot(raw.get("nodes"))
+
+
+def _extract_forward_id(data: Mapping[str, Any]) -> str:
+ forward_id = data.get("id") or data.get("resid") or data.get("message_id")
+ return str(forward_id).strip() if forward_id is not None else ""
+
+
+def _normalize_message_segments(message: Any) -> list[Mapping[str, Any]]:
+ if isinstance(message, list):
+ return [item for item in message if isinstance(item, Mapping)]
+ if isinstance(message, Mapping):
+ return [message]
+ if isinstance(message, str):
+ return [{"type": "text", "data": {"text": message}}]
+ return []
+
+
+def _iter_nested_forward_ids(nodes: list[dict[str, Any]]) -> list[str]:
+ forward_ids: list[str] = []
+ seen: set[str] = set()
+ for node in nodes:
+ raw_message = (
+ node.get("content") or node.get("message") or node.get("raw_message")
+ )
+ for segment in _normalize_message_segments(raw_message):
+ if str(segment.get("type", "") or "").strip().lower() != "forward":
+ continue
+ raw_data = segment.get("data", {})
+ data = raw_data if isinstance(raw_data, Mapping) else {}
+ forward_id = _extract_forward_id(data)
+ if forward_id and forward_id not in seen:
+ seen.add(forward_id)
+ forward_ids.append(forward_id)
+ return forward_ids
+
+
+async def snapshot_forward_tree(
+ *,
+ scope_key: str,
+ forward_id: str,
+ get_forward_messages: Callable[[str], Awaitable[Any]],
+ max_depth: int = _MAX_RECURSIVE_SNAPSHOT_DEPTH,
+ max_nodes: int = _MAX_RECURSIVE_SNAPSHOT_NODES,
+) -> None:
+ """递归抓取并保存当前可访问的合并转发树。
+
+ 同一 ``scope_key + forward_id`` 在进程内会合并并发抓取,避免多个消息或
+ 工具调用同时触发同一层 OneBot 请求。
+ """
+ normalized_scope = str(scope_key or "").strip()
+ normalized_forward_id = str(forward_id or "").strip()
+ if not normalized_scope or not normalized_forward_id:
+ return
+ if max_depth < 0 or max_nodes <= 0:
+ return
+
+ root_key = _snapshot_key(normalized_scope, normalized_forward_id)
+ inflight = _snapshot_inflight.get(root_key)
+ if inflight is not None and not inflight.done():
+ await asyncio.shield(inflight)
+ return
+
+ async def _run() -> None:
+ visited: set[str] = set()
+ remaining = max_nodes
+
+ async def _walk(current_forward_id: str, depth: int) -> None:
+ nonlocal remaining
+ normalized_current_id = str(current_forward_id or "").strip()
+ if not normalized_current_id:
+ return
+ if depth > max_depth or remaining <= 0:
+ return
+ if normalized_current_id in visited:
+ return
+ visited.add(normalized_current_id)
+ remaining -= 1
+
+ lock = _snapshot_lock(normalized_scope, normalized_current_id)
+ async with lock:
+ nodes: list[dict[str, Any]] = []
+ try:
+ nodes = await load_forward_snapshot(
+ scope_key=normalized_scope,
+ forward_id=normalized_current_id,
+ )
+ except Exception:
+ logger.debug(
+ "读取合并转发快照失败,将尝试回源: id=%s",
+ normalized_current_id,
+ exc_info=True,
+ )
+ if not nodes:
+ try:
+ raw_nodes = await get_forward_messages(normalized_current_id)
+ except Exception:
+ logger.debug(
+ "递归缓存合并转发失败: id=%s",
+ normalized_current_id,
+ exc_info=True,
+ )
+ return
+ nodes = normalize_forward_nodes_for_snapshot(raw_nodes)
+ if not nodes:
+ return
+ try:
+ await save_forward_snapshot(
+ scope_key=normalized_scope,
+ forward_id=normalized_current_id,
+ nodes=nodes,
+ )
+ except Exception:
+ logger.debug(
+ "写入合并转发快照失败: id=%s",
+ normalized_current_id,
+ exc_info=True,
+ )
+
+ if depth >= max_depth:
+ return
+ for nested_forward_id in _iter_nested_forward_ids(nodes):
+ if remaining <= 0:
+ break
+ await _walk(nested_forward_id, depth + 1)
+
+ await _walk(normalized_forward_id, 0)
+
+ task = asyncio.create_task(_run())
+ _snapshot_inflight[root_key] = task
+
+ def _forget(done: asyncio.Task[None]) -> None:
+ if _snapshot_inflight.get(root_key) is done:
+ _snapshot_inflight.pop(root_key, None)
+
+ task.add_done_callback(_forget)
+ await asyncio.shield(task)
diff --git a/src/Undefined/attachments/segments.py b/src/Undefined/attachments/segments.py
index 929348f8..5204c4a9 100644
--- a/src/Undefined/attachments/segments.py
+++ b/src/Undefined/attachments/segments.py
@@ -16,7 +16,10 @@
import httpx
-from Undefined.attachments.forward_snapshot import save_forward_snapshot
+from Undefined.attachments.forward_snapshot import (
+ load_forward_snapshot,
+ snapshot_forward_tree,
+)
from Undefined.attachments.models import RegisteredMessageAttachments
from Undefined.utils.paths import WEBUI_FILE_CACHE_DIR
from Undefined.utils.xml import escape_xml_attr
@@ -376,8 +379,8 @@ async def register_message_attachments(
get_forward_messages: 可选,拉取合并转发子消息。
register_forward_refs: 是否将顶层合并转发注册为 ``forward_`` 引用。
expand_forward_attachments: 是否递归扫描合并转发内的附件。
- snapshot_forward_messages: 是否读取当前层合并转发并缓存内层节点快照。
- snapshot_nested_forward_messages: 缓存当前层快照后,是否继续缓存直接内层转发。
+ snapshot_forward_messages: 是否读取合并转发并递归缓存可访问的节点快照。
+ snapshot_nested_forward_messages: 向后兼容参数;递归缓存已覆盖内层转发。
Returns:
已注册附件引用与归一化纯文本。
@@ -411,36 +414,6 @@ async def _fetch_forward_nodes(forward_id: str) -> list[Mapping[str, Any]]:
)
return []
- async def _snapshot_direct_nested_forwards(
- nodes: Sequence[Mapping[str, Any]],
- ) -> None:
- if not snapshot_nested_forward_messages:
- return
- for node in nodes:
- raw_message = (
- node.get("content") or node.get("message") or node.get("raw_message")
- )
- nested_segments = normalize_message_segments(raw_message)
- for nested_segment in nested_segments:
- nested_type = str(nested_segment.get("type", "") or "").strip().lower()
- if nested_type != "forward":
- continue
- raw_nested_data = nested_segment.get("data", {})
- nested_data = (
- raw_nested_data if isinstance(raw_nested_data, Mapping) else {}
- )
- nested_forward_id = _extract_forward_id(nested_data)
- if not nested_forward_id or nested_forward_id in visited_forward_ids:
- continue
- visited_forward_ids.add(nested_forward_id)
- nested_nodes = await _fetch_forward_nodes(nested_forward_id)
- if nested_nodes:
- await save_forward_snapshot(
- scope_key=scope_key,
- forward_id=nested_forward_id,
- nodes=nested_nodes,
- )
-
async def _collect_from_segments(
current_segments: Sequence[Mapping[str, Any]],
*,
@@ -591,7 +564,7 @@ async def _collect_from_segments(
elif type_ == "forward":
# 合并转发递归展开,深度上限防止无限嵌套
forward_id = _extract_forward_id(data)
- forward_nodes: list[Mapping[str, Any]] = []
+ forward_nodes: Sequence[Mapping[str, Any]] = []
if register_forward_refs and depth == 0 and forward_id:
register_forward = getattr(
registry,
@@ -621,15 +594,27 @@ async def _collect_from_segments(
)
)
if should_fetch_forward:
+ assert get_forward_messages is not None
visited_forward_ids.add(forward_id)
- forward_nodes = await _fetch_forward_nodes(forward_id)
- if snapshot_forward_messages and forward_nodes:
- await save_forward_snapshot(
+ if snapshot_forward_messages:
+ try:
+ await snapshot_forward_tree(
+ scope_key=scope_key,
+ forward_id=forward_id,
+ get_forward_messages=get_forward_messages,
+ )
+ except Exception:
+ logger.debug(
+ "[AttachmentRegistry] forward snapshot failed: id=%s",
+ forward_id,
+ exc_info=True,
+ )
+ forward_nodes = await load_forward_snapshot(
scope_key=scope_key,
forward_id=forward_id,
- nodes=forward_nodes,
)
- await _snapshot_direct_nested_forwards(forward_nodes)
+ else:
+ forward_nodes = await _fetch_forward_nodes(forward_id)
if (
expand_forward_attachments
diff --git a/src/Undefined/skills/toolsets/messages/get_forward_msg/handler.py b/src/Undefined/skills/toolsets/messages/get_forward_msg/handler.py
index a59d0ec7..be4895d1 100644
--- a/src/Undefined/skills/toolsets/messages/get_forward_msg/handler.py
+++ b/src/Undefined/skills/toolsets/messages/get_forward_msg/handler.py
@@ -8,6 +8,7 @@
from Undefined.attachments.forward_snapshot import (
load_forward_snapshot,
save_forward_snapshot,
+ snapshot_forward_tree,
)
from Undefined.attachments.segments import (
forward_ref_to_tag,
@@ -182,7 +183,6 @@ async def _register_node_segments(
register_forward_refs=True,
expand_forward_attachments=False,
snapshot_forward_messages=True,
- snapshot_nested_forward_messages=True,
)
refs = list(result.attachments) + list(result.forward_refs)
return result.normalized_text, refs
@@ -230,9 +230,23 @@ async def execute(args: dict[str, Any], context: dict[str, Any]) -> str:
forward_id=raw_forward_id,
nodes=nodes,
)
+ await snapshot_forward_tree(
+ scope_key=scope_key,
+ forward_id=raw_forward_id,
+ get_forward_messages=get_forward_msg_callback,
+ )
+ nodes = (
+ await load_forward_snapshot(
+ scope_key=scope_key,
+ forward_id=raw_forward_id,
+ )
+ or nodes
+ )
except Exception:
logger.debug(
- "保存合并转发快照失败: id=%s", raw_forward_id, exc_info=True
+ "递归保存合并转发快照失败: id=%s",
+ raw_forward_id,
+ exc_info=True,
)
except Exception as exc:
logger.exception("获取合并转发消息失败: id=%s", raw_forward_id)
diff --git a/tests/test_attachments.py b/tests/test_attachments.py
index a025cd4a..62e7ec9f 100644
--- a/tests/test_attachments.py
+++ b/tests/test_attachments.py
@@ -10,11 +10,13 @@
import httpx
import pytest
+from Undefined.attachments import forward_snapshot
from Undefined.attachments import (
AttachmentRecord,
AttachmentRegistry,
append_attachment_text,
attachment_refs_to_xml,
+ load_forward_snapshot,
register_message_attachments,
render_message_with_pic_placeholders,
)
@@ -674,6 +676,70 @@ async def _fake_get_forward(_forward_id: str) -> list[dict[str, object]]:
assert record.source_ref == "forward-1"
+@pytest.mark.asyncio
+async def test_register_message_attachments_snapshots_forward_tree_without_expansion(
+ tmp_path: Path,
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ monkeypatch.setattr(
+ forward_snapshot,
+ "FORWARD_SNAPSHOT_CACHE_DIR",
+ tmp_path / "forward_snapshots",
+ )
+ registry = AttachmentRegistry(
+ registry_path=tmp_path / "attachment_registry.json",
+ cache_dir=tmp_path / "attachments",
+ )
+ calls: list[str] = []
+
+ async def _fake_get_forward(forward_id: str) -> list[dict[str, object]]:
+ calls.append(forward_id)
+ if forward_id == "outer-forward":
+ return [
+ {
+ "message": [
+ {"type": "text", "data": {"text": "外层内容"}},
+ {"type": "forward", "data": {"id": "inner-forward"}},
+ ]
+ }
+ ]
+ if forward_id == "inner-forward":
+ return [
+ {
+ "message": [
+ {"type": "text", "data": {"text": "内层内容"}},
+ ]
+ }
+ ]
+ return []
+
+ result = await register_message_attachments(
+ registry=registry,
+ segments=[{"type": "forward", "data": {"id": "outer-forward"}}],
+ scope_key="group:10001",
+ get_forward_messages=_fake_get_forward,
+ register_forward_refs=True,
+ expand_forward_attachments=False,
+ snapshot_forward_messages=True,
+ )
+
+ assert calls == ["outer-forward", "inner-forward"]
+ assert result.attachments == []
+ assert len(result.forward_refs) == 1
+ assert result.normalized_text == f''
+ assert "外层内容" not in result.normalized_text
+ assert await load_forward_snapshot(
+ scope_key="group:10001",
+ forward_id="inner-forward",
+ ) == [
+ {
+ "message": [
+ {"type": "text", "data": {"text": "内层内容"}},
+ ]
+ }
+ ]
+
+
def test_attachment_refs_to_xml_skips_forward_refs() -> None:
xml = attachment_refs_to_xml(
[
diff --git a/tests/test_forward_snapshot.py b/tests/test_forward_snapshot.py
index 86fc2d45..09b9f272 100644
--- a/tests/test_forward_snapshot.py
+++ b/tests/test_forward_snapshot.py
@@ -1,5 +1,6 @@
from __future__ import annotations
+import asyncio
from pathlib import Path
from typing import Any
@@ -10,6 +11,7 @@
load_forward_snapshot,
normalize_forward_nodes_for_snapshot,
save_forward_snapshot,
+ snapshot_forward_tree,
)
@@ -90,3 +92,111 @@ def test_normalize_forward_nodes_for_snapshot_cleans_values() -> None:
"ignored": str(nodes[0]["ignored"]),
}
]
+
+
+@pytest.mark.asyncio
+async def test_snapshot_forward_tree_recursively_saves_nested_forwards(
+ tmp_path: Path,
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ monkeypatch.setattr(
+ forward_snapshot,
+ "FORWARD_SNAPSHOT_CACHE_DIR",
+ tmp_path / "forward_snapshots",
+ )
+ calls: list[str] = []
+
+ async def _get_forward(forward_id: str) -> list[dict[str, Any]]:
+ calls.append(forward_id)
+ if forward_id == "outer":
+ return [
+ {
+ "message": [
+ {"type": "text", "data": {"text": "外层"}},
+ {"type": "forward", "data": {"id": "inner"}},
+ ]
+ }
+ ]
+ if forward_id == "inner":
+ return [
+ {
+ "message": [
+ {"type": "text", "data": {"text": "内层"}},
+ ]
+ }
+ ]
+ return []
+
+ await snapshot_forward_tree(
+ scope_key="group:10001",
+ forward_id="outer",
+ get_forward_messages=_get_forward,
+ )
+
+ assert calls == ["outer", "inner"]
+ assert await load_forward_snapshot(
+ scope_key="group:10001",
+ forward_id="outer",
+ ) == [
+ {
+ "message": [
+ {"type": "text", "data": {"text": "外层"}},
+ {"type": "forward", "data": {"id": "inner"}},
+ ]
+ }
+ ]
+ assert await load_forward_snapshot(
+ scope_key="group:10001",
+ forward_id="inner",
+ ) == [
+ {
+ "message": [
+ {"type": "text", "data": {"text": "内层"}},
+ ]
+ }
+ ]
+
+
+@pytest.mark.asyncio
+async def test_snapshot_forward_tree_coalesces_concurrent_root_fetches(
+ tmp_path: Path,
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ monkeypatch.setattr(
+ forward_snapshot,
+ "FORWARD_SNAPSHOT_CACHE_DIR",
+ tmp_path / "forward_snapshots",
+ )
+ monkeypatch.setattr(forward_snapshot, "_snapshot_locks", {})
+ monkeypatch.setattr(forward_snapshot, "_snapshot_inflight", {})
+ calls = 0
+ release = asyncio.Event()
+
+ async def _get_forward(forward_id: str) -> list[dict[str, Any]]:
+ nonlocal calls
+ calls += 1
+ assert forward_id == "outer"
+ await release.wait()
+ return [{"message": [{"type": "text", "data": {"text": "内容"}}]}]
+
+ first = asyncio.create_task(
+ snapshot_forward_tree(
+ scope_key="group:10001",
+ forward_id="outer",
+ get_forward_messages=_get_forward,
+ )
+ )
+ await asyncio.sleep(0)
+ second = asyncio.create_task(
+ snapshot_forward_tree(
+ scope_key="group:10001",
+ forward_id="outer",
+ get_forward_messages=_get_forward,
+ )
+ )
+ await asyncio.sleep(0)
+ release.set()
+
+ await asyncio.gather(first, second)
+
+ assert calls == 1
diff --git a/tests/test_get_forward_msg_tool.py b/tests/test_get_forward_msg_tool.py
index 1d1b3817..e71cdbc2 100644
--- a/tests/test_get_forward_msg_tool.py
+++ b/tests/test_get_forward_msg_tool.py
@@ -161,6 +161,12 @@ async def _get_forward(forward_id: str) -> list[dict[str, Any]]:
assert "内层内容" in nested_result
assert seen_ids.count("nested-forward") == 1
+ raw_nested_result = await execute({"message_id": "nested-forward"}, context)
+
+ assert "来自本地快照" in raw_nested_result
+ assert "内层内容" in raw_nested_result
+ assert seen_ids.count("nested-forward") == 1
+
@pytest.mark.asyncio
async def test_get_forward_msg_reports_unavailable_forward_metadata(
diff --git a/tests/test_prompt_builder_cognitive_query.py b/tests/test_prompt_builder_cognitive_query.py
index 1f4de7ab..2c272618 100644
--- a/tests/test_prompt_builder_cognitive_query.py
+++ b/tests/test_prompt_builder_cognitive_query.py
@@ -171,3 +171,24 @@ def test_drop_current_message_if_duplicated_removes_whole_current_batch_tail() -
filtered = drop_current_message_if_duplicated(recent_messages, question)
assert [msg["message"] for msg in filtered] == ["保留的历史消息"]
+
+
+def test_drop_current_message_if_duplicated_matches_message_id_before_content() -> None:
+ recent_messages = [
+ {
+ "type": "group",
+ "message_id": "7654205319537693084",
+ "display_name": "测试用户",
+ "user_id": "10001",
+ "chat_id": "20001",
+ "timestamp": "2026-06-22 19:35:55",
+ "message": "[合并转发展开: 7654205319537693084]\n外层第一条内容",
+ }
+ ]
+ question = """
+
+"""
+
+ filtered = drop_current_message_if_duplicated(recent_messages, question)
+
+ assert filtered == []
From 4a9ffa503f17c1d06e8f22460ab42809eb870f03 Mon Sep 17 00:00:00 2001
From: Null <1708213363@qq.com>
Date: Wed, 24 Jun 2026 13:17:41 +0800
Subject: [PATCH 3/7] fix(file-analysis): resolve attachment uids for
multimodal analysis
Co-authored-by: GPT-5 Codex
---
.../agents/file_analysis_agent/README.md | 1 +
.../agents/file_analysis_agent/prompt.md | 3 +-
.../tools/analyze_multimodal/handler.py | 82 +++++++++++-
tests/test_file_analysis_attachment_uid.py | 117 ++++++++++++++++++
4 files changed, 201 insertions(+), 2 deletions(-)
diff --git a/src/Undefined/skills/agents/file_analysis_agent/README.md b/src/Undefined/skills/agents/file_analysis_agent/README.md
index d1c31224..4741df46 100644
--- a/src/Undefined/skills/agents/file_analysis_agent/README.md
+++ b/src/Undefined/skills/agents/file_analysis_agent/README.md
@@ -12,6 +12,7 @@
运行机制:
- 由 `AgentRegistry` 自动发现并注册
- 通过 `prompt` 输入任务描述并调用内部工具
+- 内部附件 UID(`pic_xxx` / `file_xxx`)由工具按当前会话作用域解析;多模态分析可直接传 UID,其他解析工具先用 `download_file` 转成本地路径
- PDF 文字提取走 `extract_pdf`;扫描版、图表、版式或指定页码范围视觉分析走 `describe_pdf_page`
开发提示:
diff --git a/src/Undefined/skills/agents/file_analysis_agent/prompt.md b/src/Undefined/skills/agents/file_analysis_agent/prompt.md
index b2476d96..2e2ac8ca 100644
--- a/src/Undefined/skills/agents/file_analysis_agent/prompt.md
+++ b/src/Undefined/skills/agents/file_analysis_agent/prompt.md
@@ -6,7 +6,7 @@
- 超大文件、乱码、缺页、格式损坏或工具无法解析时,如实说明影响,并尽量给出已能提取的部分。
附件输入规则:
-- 用户上下文里有内部附件 UID(如 `pic_xxx` / `file_xxx`)时,优先直接使用该 UID。
+- 用户上下文里有内部附件 UID(如 `pic_xxx` / `file_xxx`)时,优先使用该 UID,不要改写成文件名或 URL。
- 没有内部 UID 时,才使用显式 URL、legacy `file_id`、arXiv 标识或 Bilibili 标识。
- 不要臆造、改写或猜测附件 UID。
@@ -15,6 +15,7 @@
- 如果文件源是 Bilibili BV 号、AV 号、B 站视频链接或 b23.tv 短链,先调用共享工具 `bilibili_video`,设置 `output_mode="uid"`,拿到 `` 后再按普通视频 UID 下载和分析。
- 已经给出内部附件 UID 时,不要再调用 arXiv/Bilibili 获取工具。
- 根据用户目标选择合适工具:文本读取、文件类型检测、PDF/Office/表格/代码/压缩包/多模态分析都按内容类型处理。
+- `analyze_multimodal` 支持直接传入内部附件 UID;其他需要本地路径的工具应先调用 `download_file` 将 UID 转成本地临时文件路径。
- PDF 文本和元数据优先用 `extract_pdf`;扫描版、图表、版式、公式图、截图式页面或用户指定页码时,用 `describe_pdf_page` 做逐页视觉分析。
- `describe_pdf_page` 支持页码范围,例如 `3`、`3-5`、`3,5,8-10`;单次最多 5 页,范围过大时请缩小。
- 对图片和多模态文件,重点报告客观可见信息,例如文字、UI、场景、人物、角色、应用/游戏名称和关键元素。
diff --git a/src/Undefined/skills/agents/file_analysis_agent/tools/analyze_multimodal/handler.py b/src/Undefined/skills/agents/file_analysis_agent/tools/analyze_multimodal/handler.py
index 707653df..719d220e 100644
--- a/src/Undefined/skills/agents/file_analysis_agent/tools/analyze_multimodal/handler.py
+++ b/src/Undefined/skills/agents/file_analysis_agent/tools/analyze_multimodal/handler.py
@@ -3,9 +3,84 @@
from pathlib import Path
from typing import Any, Dict
+from Undefined.attachments import scope_from_context
+
logger = logging.getLogger(__name__)
+def _is_attachment_uid(value: str) -> bool:
+ text = value.strip()
+ return text.startswith(("pic_", "file_"))
+
+
+def _local_path_from_record(record: Any) -> Path | None:
+ local_path_raw = str(getattr(record, "local_path", "") or "").strip()
+ if not local_path_raw:
+ return None
+ local_path = (
+ local_path_raw[7:] if local_path_raw.startswith("file://") else local_path_raw
+ )
+ path = Path(local_path)
+ return path if path.is_file() else None
+
+
+def _scope_key_from_context(context: Dict[str, Any]) -> str | None:
+ scope_key = str(context.get("scope_key") or "").strip()
+ if scope_key:
+ return scope_key
+ get_scope_from_context = context.get("get_scope_from_context")
+ if callable(get_scope_from_context):
+ scope_key = str(get_scope_from_context(context) or "").strip()
+ else:
+ scope_key = str(scope_from_context(context) or "").strip()
+ return scope_key or None
+
+
+async def _resolve_attachment_uid_path(
+ file_path: str,
+ context: Dict[str, Any],
+) -> tuple[Path | None, str | None]:
+ if not _is_attachment_uid(file_path):
+ return None, None
+
+ attachment_registry = context.get("attachment_registry")
+ if attachment_registry is None:
+ return None, f"错误:无法解析附件 UID {file_path}:缺少 attachment_registry"
+
+ scope_key = _scope_key_from_context(context)
+ if not scope_key:
+ return None, f"错误:无法解析附件 UID {file_path}:缺少会话作用域"
+
+ try:
+ load = getattr(attachment_registry, "load", None)
+ if load is not None:
+ await load()
+ resolve_async = getattr(attachment_registry, "resolve_async", None)
+ if resolve_async is not None:
+ record = await resolve_async(file_path, scope_key)
+ else:
+ record = attachment_registry.resolve(file_path, scope_key)
+ except Exception:
+ logger.exception("附件 UID 解析失败: %s", file_path)
+ return None, f"错误:附件 UID 解析失败:{file_path}"
+
+ if record is None:
+ return None, f"错误:附件 UID 不存在或无权访问:{file_path}"
+
+ ensure_local_file = getattr(attachment_registry, "ensure_local_file", None)
+ if ensure_local_file is not None:
+ try:
+ record = await ensure_local_file(record)
+ except Exception:
+ logger.exception("附件 UID 本地化失败: %s", file_path)
+ return None, f"错误:附件 UID 本地化失败:{file_path}"
+
+ local_path = _local_path_from_record(record)
+ if local_path is None:
+ return None, f"错误:附件 UID 无法解析到本地文件:{file_path}"
+ return local_path, None
+
+
def _format_result(result: dict[str, str]) -> str:
"""将分析结果字典格式化为文本。"""
lines: list[str] = []
@@ -61,7 +136,12 @@ async def execute(args: Dict[str, Any], context: Dict[str, Any]) -> str:
path = Path(file_path)
if not path.exists():
- return f"错误:文件不存在 {file_path}"
+ resolved_path, error = await _resolve_attachment_uid_path(file_path, context)
+ if error:
+ return error
+ if resolved_path is None:
+ return f"错误:文件不存在 {file_path}"
+ path = resolved_path
if not path.is_file():
return f"错误:{file_path} 不是文件"
diff --git a/tests/test_file_analysis_attachment_uid.py b/tests/test_file_analysis_attachment_uid.py
index 06da1334..3d651ad3 100644
--- a/tests/test_file_analysis_attachment_uid.py
+++ b/tests/test_file_analysis_attachment_uid.py
@@ -9,6 +9,9 @@
from Undefined.skills.agents.file_analysis_agent.tools.download_file import (
handler as download_file_handler,
)
+from Undefined.skills.agents.file_analysis_agent.tools.analyze_multimodal import (
+ handler as analyze_multimodal_handler,
+)
from Undefined.utils.io import write_bytes
from Undefined.utils.paths import ensure_dir
@@ -28,6 +31,57 @@ def _download_context(
}
+class _FakeAiClient:
+ def __init__(self) -> None:
+ self.analyze_calls: list[dict[str, str]] = []
+ self.saved_history: list[dict[str, str]] = []
+
+ def get_media_history(self, media_key: str) -> list[dict[str, str]]:
+ _ = media_key
+ return []
+
+ async def analyze_multimodal(
+ self,
+ media_url: str,
+ *,
+ media_type: str,
+ prompt_extra: str,
+ ) -> dict[str, str]:
+ self.analyze_calls.append(
+ {
+ "media_url": media_url,
+ "media_type": media_type,
+ "prompt_extra": prompt_extra,
+ }
+ )
+ return {"description": "image analyzed"}
+
+ async def save_media_history(
+ self,
+ media_key: str,
+ question: str,
+ answer: str,
+ ) -> None:
+ self.saved_history.append(
+ {"media_key": media_key, "question": question, "answer": answer}
+ )
+
+
+def _analysis_context(
+ registry: AttachmentRegistry,
+ ai_client: _FakeAiClient,
+ *,
+ user_id: int = 12345,
+) -> dict[str, Any]:
+ return {
+ "attachment_registry": registry,
+ "request_type": "private",
+ "user_id": user_id,
+ "get_scope_from_context": scope_from_context,
+ "ai_client": ai_client,
+ }
+
+
@pytest.mark.asyncio
async def test_download_file_supports_internal_attachment_uid(
tmp_path: Path,
@@ -149,3 +203,66 @@ async def test_download_file_uses_random_name_for_unsafe_attachment_name(
assert downloaded.name.startswith("image_")
assert len(downloaded.name) < 64
assert downloaded.read_bytes() == b"image bytes"
+
+
+@pytest.mark.asyncio
+async def test_analyze_multimodal_supports_internal_attachment_uid(
+ tmp_path: Path,
+) -> None:
+ registry = AttachmentRegistry(
+ registry_path=tmp_path / "attachment_registry.json",
+ cache_dir=tmp_path / "attachments",
+ )
+ record = await registry.register_bytes(
+ "private:12345",
+ b"image bytes",
+ kind="image",
+ display_name="demo.jpg",
+ source_kind="test",
+ )
+ ai_client = _FakeAiClient()
+
+ result = await analyze_multimodal_handler.execute(
+ {
+ "file_path": record.uid,
+ "media_type": "image",
+ "prompt": "描述图片",
+ },
+ _analysis_context(registry, ai_client),
+ )
+
+ assert result == "描述:image analyzed"
+ assert len(ai_client.analyze_calls) == 1
+ call = ai_client.analyze_calls[0]
+ assert call["media_url"] != record.uid
+ assert Path(call["media_url"]).is_file()
+ assert Path(call["media_url"]).read_bytes() == b"image bytes"
+ assert call["media_type"] == "image"
+ assert call["prompt_extra"] == "描述图片"
+ assert ai_client.saved_history
+
+
+@pytest.mark.asyncio
+async def test_analyze_multimodal_rejects_attachment_uid_from_other_scope(
+ tmp_path: Path,
+) -> None:
+ registry = AttachmentRegistry(
+ registry_path=tmp_path / "attachment_registry.json",
+ cache_dir=tmp_path / "attachments",
+ )
+ record = await registry.register_bytes(
+ "private:12345",
+ b"image bytes",
+ kind="image",
+ display_name="demo.jpg",
+ source_kind="test",
+ )
+ ai_client = _FakeAiClient()
+
+ result = await analyze_multimodal_handler.execute(
+ {"file_path": record.uid, "media_type": "image"},
+ _analysis_context(registry, ai_client, user_id=99999),
+ )
+
+ assert result == f"错误:附件 UID 不存在或无权访问:{record.uid}"
+ assert ai_client.analyze_calls == []
From f1ec6220e033ade6163a38576c1abfba075f02d8 Mon Sep 17 00:00:00 2001
From: Null <1708213363@qq.com>
Date: Wed, 24 Jun 2026 14:05:14 +0800
Subject: [PATCH 4/7] docs: clarify release asset selection
Co-authored-by: GPT-5 Codex
---
README.md | 28 +++++++++++++++++++++++++++-
docs/app.md | 2 ++
docs/build.md | 2 ++
docs/deployment.md | 2 ++
4 files changed, 33 insertions(+), 1 deletion(-)
diff --git a/README.md b/README.md
index b7d19cc3..edb5da46 100644
--- a/README.md
+++ b/README.md
@@ -29,6 +29,32 @@
[点击添加官方实例QQ](https://qm.qq.com/q/cvjJoNysGA)
+## Release 下载速查
+
+如果只是部署和运行 QQ Bot,通常**不需要**在 GitHub Release 的 Assets 里下载任何文件。推荐按下方[快速开始](#-快速开始-源码模式)使用源码部署;只想快速体验命令行入口时,可使用 `pip install -U Undefined-bot` 或 `uv tool install Undefined-bot`。
+
+Release 里的安装包是可选组件,不是 Bot 服务本体:
+
+| 目标 | 是否需要下载 Release | 选择 |
+| --- | --- | --- |
+| 部署 / 运行 QQ Bot | 不需要 | 源码部署,启动 `uv run Undefined-webui`;或使用 `pip` / `uv tool` 安装 Python 包 |
+| 远程管理已有实例 | 可选 | `Undefined-Console-*`,用于连接 Management API 并打开远程 WebUI |
+| 使用原生聊天客户端 | 可选 | `Undefined-Chat-*`,用于连接 Runtime API 聊天 |
+| 离线安装 / 镜像缓存 Python 包 | 可选 | `undefined_bot-*.whl` 或 `undefined_bot-*.tar.gz` |
+
+平台文件选择:
+
+| 平台 | 推荐下载 |
+| --- | --- |
+| Windows x64 | `*-windows-x64-setup.exe`;批量部署或系统管理场景可选 `.msi` |
+| macOS Apple Silicon | `*-macos-arm64.dmg` |
+| macOS Intel | `*-macos-x64.dmg` |
+| Debian / Ubuntu | `*.deb` |
+| 其他 Linux x64 | `*.AppImage` |
+| Android 常见手机 / 平板 | `*-android-arm64-v8a-release.apk`;旧 32 位设备选 `armeabi-v7a`,模拟器按需选 `x86_64` / `x86` |
+
+Console 和 Chat 都需要连接到已经运行的 Undefined 服务。首次部署请先启动 `Undefined-webui`,完成配置和 Bot 启动后,再按需使用这些客户端连接。
+
## ⚡ 核心特性
- **Skills 架构**:全新设计的技能系统,将基础工具(Tools)与智能代理(Agents)分层管理,支持自动发现与注册。
@@ -126,7 +152,7 @@ uv run Undefined-webui
# cp config.toml.example config.toml
```
-> 浏览器是默认入口;如果你下载了 Release 中的桌面端或 Android 安装包,也可以在完成首轮密码设置后,连接到同一个 Management API 地址进行远程管理。
+> 浏览器是默认入口;如果你按上方 [Release 下载速查](#release-下载速查)下载了桌面端或 Android 安装包,也可以在完成首轮密码设置后,连接到同一个 Management API 地址进行远程管理。
---
diff --git a/docs/app.md b/docs/app.md
index 6267e5f2..cfd03a07 100644
--- a/docs/app.md
+++ b/docs/app.md
@@ -125,6 +125,8 @@ Android 端仍然走同一套连接模型,但 UI 目标是:
## 8. Release 产物
+普通用户如果只是部署 Bot,不需要下载这些 App 产物;先运行 `Undefined-webui` 即可。需要远程管理或原生聊天客户端时,再按 [README — Release 下载速查](../README.md#release-下载速查) 选择对应平台文件。
+
每次 `v*` tag 发布时,Release workflow 计划同步上传:
- Python:`wheel` + `sdist`
diff --git a/docs/build.md b/docs/build.md
index 6eebb9c9..2f7a0148 100644
--- a/docs/build.md
+++ b/docs/build.md
@@ -351,6 +351,8 @@ npm install
## 9. Release 产物矩阵
+面向普通用户的下载选择见 [README — Release 下载速查](../README.md#release-下载速查);本节只记录构建和发布矩阵。部署 Bot 本身不需要下载 Console / Chat 客户端安装包。
+
每次正式 Release 计划上传:
- Python
diff --git a/docs/deployment.md b/docs/deployment.md
index 6382d7c1..a4dd6f46 100644
--- a/docs/deployment.md
+++ b/docs/deployment.md
@@ -2,6 +2,8 @@
提供源码部署与 pip/uv tool 安装两种方式:**源码部署是推荐的首选方式**,功能完整且经过充分测试;pip/uv tool 安装适合快速体验,但部分功能支持尚不完善。
+> **Release 下载提示**:如果目的是部署 QQ Bot,不需要在 GitHub Release 的 Assets 中挑客户端安装包;按本文源码部署或 pip/uv tool 安装即可。Release 中的 `Undefined-Console-*` 和 `Undefined-Chat-*` 是可选客户端,选择说明见 [README — Release 下载速查](../README.md#release-下载速查)。
+
> **作为 Python 库嵌入**:若你不需要启动 QQ Bot CLI,而是要在自己的应用或测试中复用 Undefined 组件(配置、`AIClient`、Skills、认知记忆等),请参阅 [Python 库 API 参考](python-api.md) 与 [配置详解 — 库嵌入配置](configuration.md#2-库嵌入配置)。CLI 入口(`Undefined` / `Undefined-webui`)行为不受库嵌入 API 影响。
> Python 版本要求:`3.11`~`3.13`(包含)。
From 2f720cdb8da92e50ba8ced3f17b13ac36e989072 Mon Sep 17 00:00:00 2001
From: Null <1708213363@qq.com>
Date: Wed, 24 Jun 2026 19:37:35 +0800
Subject: [PATCH 5/7] chore(version): bump version to 3.6.3
---
apps/undefined-chat/package-lock.json | 4 ++--
apps/undefined-chat/package.json | 2 +-
apps/undefined-chat/src-tauri/Cargo.lock | 2 +-
apps/undefined-chat/src-tauri/Cargo.toml | 2 +-
apps/undefined-chat/src-tauri/tauri.conf.json | 2 +-
apps/undefined-console/package-lock.json | 4 ++--
apps/undefined-console/package.json | 2 +-
apps/undefined-console/src-tauri/Cargo.lock | 2 +-
apps/undefined-console/src-tauri/Cargo.toml | 2 +-
apps/undefined-console/src-tauri/tauri.conf.json | 2 +-
pyproject.toml | 2 +-
src/Undefined/__init__.py | 2 +-
uv.lock | 2 +-
13 files changed, 15 insertions(+), 15 deletions(-)
diff --git a/apps/undefined-chat/package-lock.json b/apps/undefined-chat/package-lock.json
index 0f669ad6..7a8047e4 100644
--- a/apps/undefined-chat/package-lock.json
+++ b/apps/undefined-chat/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "undefined-chat",
- "version": "3.6.2",
+ "version": "3.6.3",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "undefined-chat",
- "version": "3.6.2",
+ "version": "3.6.3",
"dependencies": {
"@tauri-apps/api": "^2.3.0",
"@tauri-apps/plugin-dialog": "^2.7.1",
diff --git a/apps/undefined-chat/package.json b/apps/undefined-chat/package.json
index 9bc32cae..ffe8de95 100644
--- a/apps/undefined-chat/package.json
+++ b/apps/undefined-chat/package.json
@@ -1,7 +1,7 @@
{
"name": "undefined-chat",
"private": true,
- "version": "3.6.2",
+ "version": "3.6.3",
"type": "module",
"scripts": {
"tauri": "tauri",
diff --git a/apps/undefined-chat/src-tauri/Cargo.lock b/apps/undefined-chat/src-tauri/Cargo.lock
index c3fbf761..1b574853 100644
--- a/apps/undefined-chat/src-tauri/Cargo.lock
+++ b/apps/undefined-chat/src-tauri/Cargo.lock
@@ -5431,7 +5431,7 @@ dependencies = [
[[package]]
name = "undefined_chat"
-version = "3.6.2"
+version = "3.6.3"
dependencies = [
"futures-util",
"keyring",
diff --git a/apps/undefined-chat/src-tauri/Cargo.toml b/apps/undefined-chat/src-tauri/Cargo.toml
index c452e146..dd288106 100644
--- a/apps/undefined-chat/src-tauri/Cargo.toml
+++ b/apps/undefined-chat/src-tauri/Cargo.toml
@@ -1,6 +1,6 @@
[package]
name = "undefined_chat"
-version = "3.6.2"
+version = "3.6.3"
description = "Undefined native chat client"
authors = ["Undefined contributors"]
license = "MIT"
diff --git a/apps/undefined-chat/src-tauri/tauri.conf.json b/apps/undefined-chat/src-tauri/tauri.conf.json
index 0b25919a..c4c04889 100644
--- a/apps/undefined-chat/src-tauri/tauri.conf.json
+++ b/apps/undefined-chat/src-tauri/tauri.conf.json
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Undefined Chat",
- "version": "3.6.2",
+ "version": "3.6.3",
"identifier": "com.undefined.chat",
"build": {
"beforeDevCommand": "npm run dev",
diff --git a/apps/undefined-console/package-lock.json b/apps/undefined-console/package-lock.json
index 60f3e9a7..95c14004 100644
--- a/apps/undefined-console/package-lock.json
+++ b/apps/undefined-console/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "undefined-console",
- "version": "3.6.2",
+ "version": "3.6.3",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "undefined-console",
- "version": "3.6.2",
+ "version": "3.6.3",
"dependencies": {
"@tauri-apps/api": "^2.3.0",
"@tauri-apps/plugin-http": "^2.3.0"
diff --git a/apps/undefined-console/package.json b/apps/undefined-console/package.json
index b025051f..660a2fa3 100644
--- a/apps/undefined-console/package.json
+++ b/apps/undefined-console/package.json
@@ -1,7 +1,7 @@
{
"name": "undefined-console",
"private": true,
- "version": "3.6.2",
+ "version": "3.6.3",
"type": "module",
"scripts": {
"tauri": "tauri",
diff --git a/apps/undefined-console/src-tauri/Cargo.lock b/apps/undefined-console/src-tauri/Cargo.lock
index 125ed043..23fff594 100644
--- a/apps/undefined-console/src-tauri/Cargo.lock
+++ b/apps/undefined-console/src-tauri/Cargo.lock
@@ -4063,7 +4063,7 @@ checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb"
[[package]]
name = "undefined_console"
-version = "3.6.2"
+version = "3.6.3"
dependencies = [
"serde",
"serde_json",
diff --git a/apps/undefined-console/src-tauri/Cargo.toml b/apps/undefined-console/src-tauri/Cargo.toml
index b0787ca2..e373c291 100644
--- a/apps/undefined-console/src-tauri/Cargo.toml
+++ b/apps/undefined-console/src-tauri/Cargo.toml
@@ -1,6 +1,6 @@
[package]
name = "undefined_console"
-version = "3.6.2"
+version = "3.6.3"
description = "Undefined cross-platform management console"
authors = ["Undefined contributors"]
license = "MIT"
diff --git a/apps/undefined-console/src-tauri/tauri.conf.json b/apps/undefined-console/src-tauri/tauri.conf.json
index 227c744b..546eabf7 100644
--- a/apps/undefined-console/src-tauri/tauri.conf.json
+++ b/apps/undefined-console/src-tauri/tauri.conf.json
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Undefined Console",
- "version": "3.6.2",
+ "version": "3.6.3",
"identifier": "com.undefined.console",
"build": {
"beforeDevCommand": "npm run dev",
diff --git a/pyproject.toml b/pyproject.toml
index 3b8062f9..9ca5f7f1 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,6 +1,6 @@
[project]
name = "Undefined-bot"
-version = "3.6.2"
+version = "3.6.3"
description = "QQ bot platform with cognitive memory architecture and multi-agent Skills, via OneBot V11."
readme = "README.md"
authors = [
diff --git a/src/Undefined/__init__.py b/src/Undefined/__init__.py
index 6472d018..8a86b5da 100644
--- a/src/Undefined/__init__.py
+++ b/src/Undefined/__init__.py
@@ -24,7 +24,7 @@
from .skills.registry import BaseRegistry as BaseRegistry
from .skills.tools import ToolRegistry as ToolRegistry
-__version__ = "3.6.2"
+__version__ = "3.6.3"
# symbol -> (module_path, attribute_name);首次访问时才 importlib 加载
_LAZY_IMPORTS: dict[str, tuple[str, str]] = {
diff --git a/uv.lock b/uv.lock
index b3cda040..3c600da3 100644
--- a/uv.lock
+++ b/uv.lock
@@ -4626,7 +4626,7 @@ wheels = [
[[package]]
name = "undefined-bot"
-version = "3.6.2"
+version = "3.6.3"
source = { editable = "." }
dependencies = [
{ name = "aiofiles" },
From d4c93db16f09fd475ff12b0e46b5644bd056728f Mon Sep 17 00:00:00 2001
From: Null <1708213363@qq.com>
Date: Wed, 24 Jun 2026 19:43:58 +0800
Subject: [PATCH 6/7] docs(changelog): add v3.6.3 notes
Co-authored-by: GPT-5 Codex
---
CHANGELOG.md | 13 +++++++++++++
1 file changed, 13 insertions(+)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 1854d3c6..5fc3e165 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,3 +1,16 @@
+## v3.6.3 合并转发快照、附件 UID 分析与 Release 下载说明
+
+本版本聚焦合并转发在协议端不可二次读取时的可用性:收到消息时先把可访问的转发树按会话保存为本地快照,后续工具读取优先复用快照,避免内层转发过期或回源失败导致 AI 看不到内容。同时补齐文件分析 Agent 对内部附件 UID 的直接解析能力,并澄清 Release 产物的下载选择。
+
+- 新增合并转发快照缓存。消息预处理阶段会递归保存当前可访问的合并转发树到 `data/cache/forward_snapshots/`,保留实时上下文中的 `` 语义;快照按会话隔离,并合并同一转发的并发抓取。
+- 改进 `messages.get_forward_msg`。工具支持 `forward_` UID 与原始 ID 优先读取本地快照,缺失时再回源 OneBot 并补写快照;协议端无法二次读取内层转发时,会返回明确诊断和可见原始字段,而不是只报空内容。
+- 修复合并转发当前轮上下文重复。Prompt 构建在剔除当前消息历史副本时优先匹配 `message_id`,避免历史中的递归展开文本与实时 `` 在同一轮重复注入。
+- 增强文件分析附件链路。`file_analysis_agent` 的 `analyze_multimodal` 可直接接收 `pic_` / `file_` UID,按当前会话作用域解析并本地化后再分析;Agent 提示同步明确不要改写或猜测附件 UID。
+- 澄清 Release 下载选择。README 与部署、构建、App 文档补充说明:部署 QQ Bot 通常不需要下载 Release 客户端安装包,Console / Chat 仅作为远程管理或原生聊天的可选组件。
+- 同步 3.6.3 版本号与测试覆盖。Python 包、Console、Chat、Tauri 与 lock 文件版本统一更新;新增合并转发快照、转发读取回退、多模态附件 UID 和当前消息去重回归测试。
+
+---
+
## v3.6.2 合并转发 UID、表情包跟进与协调器清理
本版本继续收敛消息附件链路和协调器结构:合并转发可作为会话内 `forward_` UID 被 AI 按层读取和复用,表情包跟进策略更稳定,同时移除旧版协调器兼容模块,并完成 3.6.2 版本号同步。
From de73819a6168a949029d1e2eff0c5ba5a3a7f252 Mon Sep 17 00:00:00 2001
From: Null <1708213363@qq.com>
Date: Wed, 24 Jun 2026 20:09:18 +0800
Subject: [PATCH 7/7] fix(messages): address forward snapshot review comments
Co-authored-by: GPT-5 Codex
---
docs/deployment.md | 4 +-
src/Undefined/attachments/forward_snapshot.py | 85 +++++++++++--------
src/Undefined/attachments/segments.py | 13 +--
.../tools/analyze_multimodal/handler.py | 4 -
tests/test_attachments.py | 72 +++++++++++++++-
tests/test_file_analysis_attachment_uid.py | 6 +-
tests/test_forward_snapshot.py | 40 ++++-----
7 files changed, 157 insertions(+), 67 deletions(-)
diff --git a/docs/deployment.md b/docs/deployment.md
index a4dd6f46..36889c81 100644
--- a/docs/deployment.md
+++ b/docs/deployment.md
@@ -3,9 +3,9 @@
提供源码部署与 pip/uv tool 安装两种方式:**源码部署是推荐的首选方式**,功能完整且经过充分测试;pip/uv tool 安装适合快速体验,但部分功能支持尚不完善。
> **Release 下载提示**:如果目的是部署 QQ Bot,不需要在 GitHub Release 的 Assets 中挑客户端安装包;按本文源码部署或 pip/uv tool 安装即可。Release 中的 `Undefined-Console-*` 和 `Undefined-Chat-*` 是可选客户端,选择说明见 [README — Release 下载速查](../README.md#release-下载速查)。
-
+>
> **作为 Python 库嵌入**:若你不需要启动 QQ Bot CLI,而是要在自己的应用或测试中复用 Undefined 组件(配置、`AIClient`、Skills、认知记忆等),请参阅 [Python 库 API 参考](python-api.md) 与 [配置详解 — 库嵌入配置](configuration.md#2-库嵌入配置)。CLI 入口(`Undefined` / `Undefined-webui`)行为不受库嵌入 API 影响。
-
+>
> Python 版本要求:`3.11`~`3.13`(包含)。
>
> 若使用 `uv`,通常不需要你手动限制系统 Python 版本;`uv` 会根据项目约束自动选择/下载兼容解释器。
diff --git a/src/Undefined/attachments/forward_snapshot.py b/src/Undefined/attachments/forward_snapshot.py
index 4b39f0ac..5ea8a38f 100644
--- a/src/Undefined/attachments/forward_snapshot.py
+++ b/src/Undefined/attachments/forward_snapshot.py
@@ -22,6 +22,7 @@
_MAX_RECURSIVE_SNAPSHOT_DEPTH = 3
_MAX_RECURSIVE_SNAPSHOT_NODES = 50
_snapshot_locks: dict[str, asyncio.Lock] = {}
+_snapshot_lock_users: dict[str, int] = {}
_snapshot_inflight: dict[str, asyncio.Task[None]] = {}
@@ -34,13 +35,24 @@ def _snapshot_path(scope_key: str, forward_id: str) -> Path:
return FORWARD_SNAPSHOT_CACHE_DIR / f"{_snapshot_key(scope_key, forward_id)}.json"
-def _snapshot_lock(scope_key: str, forward_id: str) -> asyncio.Lock:
+def _snapshot_lock(scope_key: str, forward_id: str) -> tuple[str, asyncio.Lock]:
key = _snapshot_key(scope_key, forward_id)
lock = _snapshot_locks.get(key)
if lock is None:
lock = asyncio.Lock()
_snapshot_locks[key] = lock
- return lock
+ _snapshot_lock_users[key] = _snapshot_lock_users.get(key, 0) + 1
+ return key, lock
+
+
+def _release_snapshot_lock(key: str, lock: asyncio.Lock) -> None:
+ users = _snapshot_lock_users.get(key, 0) - 1
+ if users > 0:
+ _snapshot_lock_users[key] = users
+ return
+ _snapshot_lock_users.pop(key, None)
+ if _snapshot_locks.get(key) is lock and not lock.locked():
+ _snapshot_locks.pop(key, None)
def _clean_json_value(value: Any) -> Any:
@@ -209,45 +221,50 @@ async def _walk(current_forward_id: str, depth: int) -> None:
visited.add(normalized_current_id)
remaining -= 1
- lock = _snapshot_lock(normalized_scope, normalized_current_id)
- async with lock:
- nodes: list[dict[str, Any]] = []
- try:
- nodes = await load_forward_snapshot(
- scope_key=normalized_scope,
- forward_id=normalized_current_id,
- )
- except Exception:
- logger.debug(
- "读取合并转发快照失败,将尝试回源: id=%s",
- normalized_current_id,
- exc_info=True,
- )
- if not nodes:
+ lock_key, lock = _snapshot_lock(normalized_scope, normalized_current_id)
+ try:
+ async with lock:
+ nodes: list[dict[str, Any]] = []
try:
- raw_nodes = await get_forward_messages(normalized_current_id)
+ nodes = await load_forward_snapshot(
+ scope_key=normalized_scope,
+ forward_id=normalized_current_id,
+ )
except Exception:
logger.debug(
- "递归缓存合并转发失败: id=%s",
+ "读取合并转发快照失败,将尝试回源: id=%s",
normalized_current_id,
exc_info=True,
)
+ if not nodes:
+ try:
+ raw_nodes = await get_forward_messages(
+ normalized_current_id
+ )
+ except Exception:
+ logger.debug(
+ "递归缓存合并转发失败: id=%s",
+ normalized_current_id,
+ exc_info=True,
+ )
+ return
+ nodes = normalize_forward_nodes_for_snapshot(raw_nodes)
+ if not nodes:
return
- nodes = normalize_forward_nodes_for_snapshot(raw_nodes)
- if not nodes:
- return
- try:
- await save_forward_snapshot(
- scope_key=normalized_scope,
- forward_id=normalized_current_id,
- nodes=nodes,
- )
- except Exception:
- logger.debug(
- "写入合并转发快照失败: id=%s",
- normalized_current_id,
- exc_info=True,
- )
+ try:
+ await save_forward_snapshot(
+ scope_key=normalized_scope,
+ forward_id=normalized_current_id,
+ nodes=nodes,
+ )
+ except Exception:
+ logger.debug(
+ "写入合并转发快照失败: id=%s",
+ normalized_current_id,
+ exc_info=True,
+ )
+ finally:
+ _release_snapshot_lock(lock_key, lock)
if depth >= max_depth:
return
diff --git a/src/Undefined/attachments/segments.py b/src/Undefined/attachments/segments.py
index 5204c4a9..b79fed55 100644
--- a/src/Undefined/attachments/segments.py
+++ b/src/Undefined/attachments/segments.py
@@ -400,6 +400,9 @@ async def register_message_attachments(
)
visited_forward_ids: set[str] = set()
+ should_snapshot_forward_messages = (
+ snapshot_forward_messages or snapshot_nested_forward_messages
+ )
async def _fetch_forward_nodes(forward_id: str) -> list[Mapping[str, Any]]:
if get_forward_messages is None:
@@ -586,7 +589,7 @@ async def _collect_from_segments(
and forward_id
and forward_id not in visited_forward_ids
and (
- snapshot_forward_messages
+ should_snapshot_forward_messages
or (
expand_forward_attachments
and depth < _FORWARD_ATTACHMENT_MAX_DEPTH
@@ -596,7 +599,7 @@ async def _collect_from_segments(
if should_fetch_forward:
assert get_forward_messages is not None
visited_forward_ids.add(forward_id)
- if snapshot_forward_messages:
+ if should_snapshot_forward_messages:
try:
await snapshot_forward_tree(
scope_key=scope_key,
@@ -613,6 +616,8 @@ async def _collect_from_segments(
scope_key=scope_key,
forward_id=forward_id,
)
+ if not forward_nodes:
+ forward_nodes = await _fetch_forward_nodes(forward_id)
else:
forward_nodes = await _fetch_forward_nodes(forward_id)
@@ -623,9 +628,7 @@ async def _collect_from_segments(
and forward_id
):
if not forward_nodes:
- if forward_id in visited_forward_ids:
- forward_nodes = []
- else:
+ if forward_id not in visited_forward_ids:
visited_forward_ids.add(forward_id)
forward_nodes = await _fetch_forward_nodes(forward_id)
if not forward_nodes:
diff --git a/src/Undefined/skills/agents/file_analysis_agent/tools/analyze_multimodal/handler.py b/src/Undefined/skills/agents/file_analysis_agent/tools/analyze_multimodal/handler.py
index 719d220e..1054df0f 100644
--- a/src/Undefined/skills/agents/file_analysis_agent/tools/analyze_multimodal/handler.py
+++ b/src/Undefined/skills/agents/file_analysis_agent/tools/analyze_multimodal/handler.py
@@ -3,8 +3,6 @@
from pathlib import Path
from typing import Any, Dict
-from Undefined.attachments import scope_from_context
-
logger = logging.getLogger(__name__)
@@ -31,8 +29,6 @@ def _scope_key_from_context(context: Dict[str, Any]) -> str | None:
get_scope_from_context = context.get("get_scope_from_context")
if callable(get_scope_from_context):
scope_key = str(get_scope_from_context(context) or "").strip()
- else:
- scope_key = str(scope_from_context(context) or "").strip()
return scope_key or None
diff --git a/tests/test_attachments.py b/tests/test_attachments.py
index 62e7ec9f..8a4dda66 100644
--- a/tests/test_attachments.py
+++ b/tests/test_attachments.py
@@ -11,6 +11,7 @@
import pytest
from Undefined.attachments import forward_snapshot
+from Undefined.attachments import segments as attachment_segments
from Undefined.attachments import (
AttachmentRecord,
AttachmentRegistry,
@@ -677,15 +678,27 @@ async def _fake_get_forward(_forward_id: str) -> list[dict[str, object]]:
@pytest.mark.asyncio
+@pytest.mark.parametrize(
+ ("snapshot_forward_messages", "snapshot_nested_forward_messages"),
+ [
+ (True, False),
+ (False, True),
+ ],
+)
async def test_register_message_attachments_snapshots_forward_tree_without_expansion(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
+ snapshot_forward_messages: bool,
+ snapshot_nested_forward_messages: bool,
) -> None:
monkeypatch.setattr(
forward_snapshot,
"FORWARD_SNAPSHOT_CACHE_DIR",
tmp_path / "forward_snapshots",
)
+ monkeypatch.setattr(forward_snapshot, "_snapshot_locks", {})
+ monkeypatch.setattr(forward_snapshot, "_snapshot_lock_users", {})
+ monkeypatch.setattr(forward_snapshot, "_snapshot_inflight", {})
registry = AttachmentRegistry(
registry_path=tmp_path / "attachment_registry.json",
cache_dir=tmp_path / "attachments",
@@ -720,7 +733,8 @@ async def _fake_get_forward(forward_id: str) -> list[dict[str, object]]:
get_forward_messages=_fake_get_forward,
register_forward_refs=True,
expand_forward_attachments=False,
- snapshot_forward_messages=True,
+ snapshot_forward_messages=snapshot_forward_messages,
+ snapshot_nested_forward_messages=snapshot_nested_forward_messages,
)
assert calls == ["outer-forward", "inner-forward"]
@@ -740,6 +754,62 @@ async def _fake_get_forward(forward_id: str) -> list[dict[str, object]]:
]
+@pytest.mark.asyncio
+async def test_register_message_attachments_empty_snapshot_falls_back_to_resolver(
+ tmp_path: Path,
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ registry = AttachmentRegistry(
+ registry_path=tmp_path / "attachment_registry.json",
+ cache_dir=tmp_path / "attachments",
+ )
+ encoded_image = base64.b64encode(_PNG_BYTES).decode("ascii")
+ calls: list[str] = []
+
+ async def _noop_snapshot_forward_tree(**_kwargs: Any) -> None:
+ return None
+
+ async def _empty_load_forward_snapshot(**_kwargs: Any) -> list[dict[str, Any]]:
+ return []
+
+ async def _fake_get_forward(forward_id: str) -> list[dict[str, object]]:
+ calls.append(forward_id)
+ return [
+ {
+ "message": [
+ {
+ "type": "image",
+ "data": {"file": f"base64://{encoded_image}"},
+ }
+ ]
+ }
+ ]
+
+ monkeypatch.setattr(
+ attachment_segments,
+ "snapshot_forward_tree",
+ _noop_snapshot_forward_tree,
+ )
+ monkeypatch.setattr(
+ attachment_segments,
+ "load_forward_snapshot",
+ _empty_load_forward_snapshot,
+ )
+
+ result = await register_message_attachments(
+ registry=registry,
+ segments=[{"type": "forward", "data": {"id": "outer-forward"}}],
+ scope_key="group:10001",
+ get_forward_messages=_fake_get_forward,
+ expand_forward_attachments=True,
+ snapshot_forward_messages=True,
+ )
+
+ assert calls == ["outer-forward"]
+ assert len(result.attachments) == 1
+ assert result.attachments[0]["uid"].startswith("pic_")
+
+
def test_attachment_refs_to_xml_skips_forward_refs() -> None:
xml = attachment_refs_to_xml(
[
diff --git a/tests/test_file_analysis_attachment_uid.py b/tests/test_file_analysis_attachment_uid.py
index 3d651ad3..2840fad4 100644
--- a/tests/test_file_analysis_attachment_uid.py
+++ b/tests/test_file_analysis_attachment_uid.py
@@ -1,5 +1,6 @@
from __future__ import annotations
+import asyncio
from pathlib import Path
from typing import Any
@@ -235,8 +236,9 @@ async def test_analyze_multimodal_supports_internal_attachment_uid(
assert len(ai_client.analyze_calls) == 1
call = ai_client.analyze_calls[0]
assert call["media_url"] != record.uid
- assert Path(call["media_url"]).is_file()
- assert Path(call["media_url"]).read_bytes() == b"image bytes"
+ media_path = Path(call["media_url"])
+ assert await asyncio.to_thread(media_path.is_file)
+ assert await asyncio.to_thread(media_path.read_bytes) == b"image bytes"
assert call["media_type"] == "image"
assert call["prompt_extra"] == "描述图片"
assert ai_client.saved_history
diff --git a/tests/test_forward_snapshot.py b/tests/test_forward_snapshot.py
index 09b9f272..4d6f8f0b 100644
--- a/tests/test_forward_snapshot.py
+++ b/tests/test_forward_snapshot.py
@@ -20,8 +20,7 @@ def __str__(self) -> str:
return "odd-value"
-@pytest.mark.asyncio
-async def test_forward_snapshot_round_trip(
+def _reset_forward_snapshot_state(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
@@ -30,6 +29,17 @@ async def test_forward_snapshot_round_trip(
"FORWARD_SNAPSHOT_CACHE_DIR",
tmp_path / "forward_snapshots",
)
+ monkeypatch.setattr(forward_snapshot, "_snapshot_locks", {})
+ monkeypatch.setattr(forward_snapshot, "_snapshot_lock_users", {})
+ monkeypatch.setattr(forward_snapshot, "_snapshot_inflight", {})
+
+
+@pytest.mark.asyncio
+async def test_forward_snapshot_round_trip(
+ tmp_path: Path,
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ _reset_forward_snapshot_state(tmp_path, monkeypatch)
saved = await save_forward_snapshot(
scope_key="group:10001",
@@ -60,11 +70,7 @@ async def test_forward_snapshot_is_scoped(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
- monkeypatch.setattr(
- forward_snapshot,
- "FORWARD_SNAPSHOT_CACHE_DIR",
- tmp_path / "forward_snapshots",
- )
+ _reset_forward_snapshot_state(tmp_path, monkeypatch)
await save_forward_snapshot(
scope_key="group:10001",
@@ -99,11 +105,7 @@ async def test_snapshot_forward_tree_recursively_saves_nested_forwards(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
- monkeypatch.setattr(
- forward_snapshot,
- "FORWARD_SNAPSHOT_CACHE_DIR",
- tmp_path / "forward_snapshots",
- )
+ _reset_forward_snapshot_state(tmp_path, monkeypatch)
calls: list[str] = []
async def _get_forward(forward_id: str) -> list[dict[str, Any]]:
@@ -155,6 +157,9 @@ async def _get_forward(forward_id: str) -> list[dict[str, Any]]:
]
}
]
+ assert forward_snapshot._snapshot_locks == {}
+ assert forward_snapshot._snapshot_lock_users == {}
+ assert forward_snapshot._snapshot_inflight == {}
@pytest.mark.asyncio
@@ -162,13 +167,7 @@ async def test_snapshot_forward_tree_coalesces_concurrent_root_fetches(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
- monkeypatch.setattr(
- forward_snapshot,
- "FORWARD_SNAPSHOT_CACHE_DIR",
- tmp_path / "forward_snapshots",
- )
- monkeypatch.setattr(forward_snapshot, "_snapshot_locks", {})
- monkeypatch.setattr(forward_snapshot, "_snapshot_inflight", {})
+ _reset_forward_snapshot_state(tmp_path, monkeypatch)
calls = 0
release = asyncio.Event()
@@ -200,3 +199,6 @@ async def _get_forward(forward_id: str) -> list[dict[str, Any]]:
await asyncio.gather(first, second)
assert calls == 1
+ assert forward_snapshot._snapshot_locks == {}
+ assert forward_snapshot._snapshot_lock_users == {}
+ assert forward_snapshot._snapshot_inflight == {}