netizen-cli 0.10.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- netizen_cli/__init__.py +3 -0
- netizen_cli/__main__.py +4 -0
- netizen_cli/admin/__init__.py +1 -0
- netizen_cli/admin/auth.py +928 -0
- netizen_cli/admin/errors.py +9 -0
- netizen_cli/admin/port_config.py +115 -0
- netizen_cli/admin/presentation.py +257 -0
- netizen_cli/admin/queries.py +337 -0
- netizen_cli/admin/static/admin.css +260 -0
- netizen_cli/admin/static/admin.js +2898 -0
- netizen_cli/admin/static/index.html +327 -0
- netizen_cli/admin/transport.py +935 -0
- netizen_cli/admin/web.py +2717 -0
- netizen_cli/bindings.py +3215 -0
- netizen_cli/builtin_skills.py +93 -0
- netizen_cli/cards/__init__.py +105 -0
- netizen_cli/cards/callbacks.py +565 -0
- netizen_cli/cards/controls.py +2273 -0
- netizen_cli/cards/defaults.py +213 -0
- netizen_cli/cards/model_info.py +80 -0
- netizen_cli/cards/questions.py +220 -0
- netizen_cli/cards/reply.py +2247 -0
- netizen_cli/cards/scheduled.py +836 -0
- netizen_cli/channel/__init__.py +1 -0
- netizen_cli/channel/completion_mentions.py +60 -0
- netizen_cli/channel/input_preparation.py +644 -0
- netizen_cli/channel/messages.py +57 -0
- netizen_cli/channel/ports.py +52 -0
- netizen_cli/channel/question_inputs.py +51 -0
- netizen_cli/channel/reactions.py +293 -0
- netizen_cli/channel/reply_presenter.py +1505 -0
- netizen_cli/channel/topics.py +70 -0
- netizen_cli/channel_app.py +6593 -0
- netizen_cli/cli.py +287 -0
- netizen_cli/cli_data.py +536 -0
- netizen_cli/cli_packages.py +526 -0
- netizen_cli/cli_services.py +651 -0
- netizen_cli/cli_setup.py +242 -0
- netizen_cli/cli_update.py +303 -0
- netizen_cli/cli_update_restore.py +53 -0
- netizen_cli/cli_update_worker.py +333 -0
- netizen_cli/codex_runtime.py +7125 -0
- netizen_cli/completion_mention.py +16 -0
- netizen_cli/database_migrations.py +218 -0
- netizen_cli/defaults/__init__.py +5 -0
- netizen_cli/defaults/models.py +39 -0
- netizen_cli/defaults/service.py +232 -0
- netizen_cli/defaults/store.py +260 -0
- netizen_cli/deployment/__init__.py +1 -0
- netizen_cli/deployment/restart_worker.py +134 -0
- netizen_cli/deployment/update_executor.py +258 -0
- netizen_cli/deployment/update_protocol.py +281 -0
- netizen_cli/domain.py +416 -0
- netizen_cli/error_messages.py +124 -0
- netizen_cli/experience.py +531 -0
- netizen_cli/feishu_app_onboarding.py +187 -0
- netizen_cli/feishu_app_permissions.py +123 -0
- netizen_cli/git_status.py +63 -0
- netizen_cli/image_inputs.py +579 -0
- netizen_cli/instance.py +84 -0
- netizen_cli/lark_app.py +125 -0
- netizen_cli/main.py +903 -0
- netizen_cli/management/__init__.py +83 -0
- netizen_cli/management/blocking_io.py +352 -0
- netizen_cli/management/chat_labels.py +266 -0
- netizen_cli/management/coordination.py +32 -0
- netizen_cli/management/service.py +2187 -0
- netizen_cli/management/updates.py +214 -0
- netizen_cli/markdown_images.py +78 -0
- netizen_cli/message_content.py +786 -0
- netizen_cli/message_history.py +643 -0
- netizen_cli/message_preparation.py +60 -0
- netizen_cli/message_projection.py +923 -0
- netizen_cli/migrations/__init__.py +1 -0
- netizen_cli/migrations/schema.py +103 -0
- netizen_cli/migrations/v14.py +438 -0
- netizen_cli/model_settings.py +269 -0
- netizen_cli/package_resources.py +22 -0
- netizen_cli/projects.py +327 -0
- netizen_cli/prompt_projection.py +327 -0
- netizen_cli/quoted_context.py +312 -0
- netizen_cli/resources/config.example.yaml +35 -0
- netizen_cli/resources/skills/netizen-lark/SKILL.md +64 -0
- netizen_cli/resources/skills/netizen-user-guide/SKILL.md +37 -0
- netizen_cli/resources/skills/netizen-user-guide/references/user-guide.md +842 -0
- netizen_cli/result_images.py +123 -0
- netizen_cli/runtime/__init__.py +1 -0
- netizen_cli/runtime/contracts.py +792 -0
- netizen_cli/runtime/name_writes.py +67 -0
- netizen_cli/runtime/thread_naming.py +451 -0
- netizen_cli/schedules/__init__.py +1 -0
- netizen_cli/schedules/mcp.py +535 -0
- netizen_cli/schedules/models.py +394 -0
- netizen_cli/schedules/scheduler.py +374 -0
- netizen_cli/schedules/service.py +766 -0
- netizen_cli/schedules/store.py +771 -0
- netizen_cli/sdk_gap_adapter.py +1151 -0
- netizen_cli/service_launcher.py +583 -0
- netizen_cli/session_settings.py +126 -0
- netizen_cli/settings.py +216 -0
- netizen_cli/skill_references.py +40 -0
- netizen_cli/terminal_cleanup.py +155 -0
- netizen_cli/turn_activity.py +688 -0
- netizen_cli/turn_files.py +812 -0
- netizen_cli/turn_patch_children.py +254 -0
- netizen_cli/turn_plan_observer.py +315 -0
- netizen_cli/user_questions.py +106 -0
- netizen_cli-0.10.0.dist-info/METADATA +18 -0
- netizen_cli-0.10.0.dist-info/RECORD +112 -0
- netizen_cli-0.10.0.dist-info/WHEEL +5 -0
- netizen_cli-0.10.0.dist-info/entry_points.txt +2 -0
- netizen_cli-0.10.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,786 @@
|
|
|
1
|
+
"""Shared content projection over the Channel SDK public normalized messages.
|
|
2
|
+
|
|
3
|
+
This module owns message-type and resource semantics. Message acquisition,
|
|
4
|
+
source identity, prompt intent, historical selection, and wire envelopes remain
|
|
5
|
+
with their callers.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from dataclasses import replace
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from lark_channel import (
|
|
14
|
+
MergeForwardContent,
|
|
15
|
+
MergeForwardItem,
|
|
16
|
+
PostContent,
|
|
17
|
+
TextContent,
|
|
18
|
+
flatten_content,
|
|
19
|
+
)
|
|
20
|
+
from lark_channel.channel.normalize import MentionExtraction, resolve_mentions
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
_FORWARD_ITEM_LIMIT = 50
|
|
24
|
+
_FORWARD_DEPTH_LIMIT = 3
|
|
25
|
+
_FORWARD_DEPTH_ERROR = (
|
|
26
|
+
"合并转发嵌套超过最大深度 3,本条消息未执行;请拆分后重试。"
|
|
27
|
+
)
|
|
28
|
+
_FORWARD_TEXT_LIMIT = 16_000
|
|
29
|
+
_FORWARD_MEDIA_NOTICE = (
|
|
30
|
+
"合并转发仅提供可见文字和附件描述;"
|
|
31
|
+
"未读取图片像素、文件正文、音视频或表情内容。"
|
|
32
|
+
"仅限 SDK 返回的展开内容,不代表原话题完整历史。"
|
|
33
|
+
)
|
|
34
|
+
_FORWARD_TRUNCATION_NOTICE = "合并转发内容已截断,部分消息或文字未提供。"
|
|
35
|
+
_CARD_NODE_LIMIT = 4_096
|
|
36
|
+
_CARD_DEPTH_LIMIT = 32
|
|
37
|
+
_CARD_TEXT_LIMIT = 256_000
|
|
38
|
+
_CARD_HIDDEN_FIELDS = frozenset(
|
|
39
|
+
{
|
|
40
|
+
"value", "confirm", "options", "behaviors", "events",
|
|
41
|
+
"disabled_tips", "initial_option", "initial_value",
|
|
42
|
+
"selected_values", "tooltip",
|
|
43
|
+
}
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
TEXT_TYPES = frozenset({"text", "post"})
|
|
48
|
+
MATERIAL_MESSAGE_TYPES = frozenset({"interactive", "merge_forward"})
|
|
49
|
+
STRUCTURED_TYPES = frozenset(
|
|
50
|
+
{
|
|
51
|
+
"interactive",
|
|
52
|
+
"calendar",
|
|
53
|
+
"general_calendar",
|
|
54
|
+
"share_calendar_event",
|
|
55
|
+
"location",
|
|
56
|
+
"video_chat",
|
|
57
|
+
"todo",
|
|
58
|
+
"vote",
|
|
59
|
+
"hongbao",
|
|
60
|
+
}
|
|
61
|
+
)
|
|
62
|
+
METADATA_TYPES = frozenset(
|
|
63
|
+
{
|
|
64
|
+
"image",
|
|
65
|
+
"file",
|
|
66
|
+
"folder",
|
|
67
|
+
"audio",
|
|
68
|
+
"media",
|
|
69
|
+
"sticker",
|
|
70
|
+
"share_chat",
|
|
71
|
+
"share_user",
|
|
72
|
+
}
|
|
73
|
+
)
|
|
74
|
+
SUPPORTED_MESSAGE_TYPES = (
|
|
75
|
+
TEXT_TYPES | STRUCTURED_TYPES | METADATA_TYPES | {"merge_forward"}
|
|
76
|
+
)
|
|
77
|
+
PLACEHOLDER_TEXT = frozenset(
|
|
78
|
+
{
|
|
79
|
+
"",
|
|
80
|
+
"[interactive]",
|
|
81
|
+
"[unsupported message]",
|
|
82
|
+
"<forwarded_messages/>",
|
|
83
|
+
}
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class HistoricalMessageError(RuntimeError):
|
|
88
|
+
"""A historical-message failure that is safe to show to the user."""
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
class HistoricalMessageUnavailable(HistoricalMessageError):
|
|
92
|
+
"""A selected message could not be projected faithfully."""
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
class UnsupportedHistoricalMessage(HistoricalMessageError):
|
|
96
|
+
"""A selected message has no supported historical representation."""
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
class HistoricalMessageContractError(HistoricalMessageError):
|
|
100
|
+
"""Public normalized fields contradict each other."""
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def normalized_content_type(
|
|
104
|
+
message: Any,
|
|
105
|
+
*,
|
|
106
|
+
default: str = "unknown",
|
|
107
|
+
conflict_error: type[Exception] = HistoricalMessageContractError,
|
|
108
|
+
) -> str:
|
|
109
|
+
"""Resolve the SDK's typed/raw message kind with one shared conflict gate."""
|
|
110
|
+
|
|
111
|
+
content = getattr(message, "content", None)
|
|
112
|
+
kind = _nonempty_string(getattr(content, "kind", None))
|
|
113
|
+
raw_kind = _nonempty_string(getattr(message, "raw_content_type", None))
|
|
114
|
+
normalized_raw_kind = "media" if raw_kind == "video" else raw_kind
|
|
115
|
+
if kind == "unknown" and normalized_raw_kind is not None:
|
|
116
|
+
return normalized_raw_kind
|
|
117
|
+
if (
|
|
118
|
+
kind is not None
|
|
119
|
+
and normalized_raw_kind is not None
|
|
120
|
+
and kind != normalized_raw_kind
|
|
121
|
+
):
|
|
122
|
+
raise conflict_error(
|
|
123
|
+
"Channel SDK 返回了相互冲突的消息类型,"
|
|
124
|
+
"本条消息未执行;请联系维护者检查 SDK 兼容性。"
|
|
125
|
+
)
|
|
126
|
+
return kind or normalized_raw_kind or default
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def project_message_content(
|
|
130
|
+
message: Any,
|
|
131
|
+
*,
|
|
132
|
+
message_type: str,
|
|
133
|
+
interactive_fallback_text: str | None = None,
|
|
134
|
+
read_image_keys: tuple[str, ...] = (),
|
|
135
|
+
request_text: str | None = None,
|
|
136
|
+
) -> dict[str, Any]:
|
|
137
|
+
"""Project normalized content independently of source and wire envelope."""
|
|
138
|
+
|
|
139
|
+
content = getattr(message, "content", None)
|
|
140
|
+
if message_type == "merge_forward":
|
|
141
|
+
return _project_merge_forward(message)
|
|
142
|
+
read_image_key_set = frozenset(read_image_keys)
|
|
143
|
+
if message_type == "interactive":
|
|
144
|
+
_validate_interactive_content(content)
|
|
145
|
+
read_image_key_set = frozenset()
|
|
146
|
+
resources = _resource_metadata(message, read_image_keys=read_image_key_set)
|
|
147
|
+
if message_type == "post" and read_image_keys:
|
|
148
|
+
public_images = {
|
|
149
|
+
item.get("file_key"): item
|
|
150
|
+
for item in resources
|
|
151
|
+
if item.get("type") == "image"
|
|
152
|
+
}
|
|
153
|
+
visible_images = [
|
|
154
|
+
public_images.get(key)
|
|
155
|
+
or _resource_item("image", file_key=key, content_read=True)
|
|
156
|
+
for key in read_image_keys
|
|
157
|
+
]
|
|
158
|
+
resources = visible_images + [
|
|
159
|
+
item for item in resources if item.get("type") != "image"
|
|
160
|
+
]
|
|
161
|
+
truncated = bool(getattr(content, "truncated", False))
|
|
162
|
+
|
|
163
|
+
if message_type in TEXT_TYPES:
|
|
164
|
+
if request_text is not None and not isinstance(request_text, str):
|
|
165
|
+
raise TypeError("request_text must be a string")
|
|
166
|
+
text = (
|
|
167
|
+
request_text
|
|
168
|
+
if request_text is not None
|
|
169
|
+
else usable_text(getattr(message, "content_text", ""))
|
|
170
|
+
)
|
|
171
|
+
if text is None:
|
|
172
|
+
raise HistoricalMessageUnavailable(
|
|
173
|
+
"历史消息没有可读取的文本内容,请复制内容后重试。"
|
|
174
|
+
)
|
|
175
|
+
return {
|
|
176
|
+
"text": text,
|
|
177
|
+
"content_fidelity": (
|
|
178
|
+
"full_multimodal"
|
|
179
|
+
if resources and all(item["content_read"] for item in resources)
|
|
180
|
+
else "partial"
|
|
181
|
+
if resources
|
|
182
|
+
else "full_text"
|
|
183
|
+
),
|
|
184
|
+
"content_read": True,
|
|
185
|
+
"content_metadata": {},
|
|
186
|
+
"resources": resources,
|
|
187
|
+
"truncated": truncated,
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
if message_type in STRUCTURED_TYPES:
|
|
191
|
+
text = usable_text(getattr(message, "content_text", ""))
|
|
192
|
+
if message_type == "interactive" and text is None:
|
|
193
|
+
text = usable_text(interactive_fallback_text)
|
|
194
|
+
if text is None:
|
|
195
|
+
raise HistoricalMessageUnavailable(
|
|
196
|
+
"历史应用消息没有可提取的可见内容,"
|
|
197
|
+
"请复制内容后重试。"
|
|
198
|
+
)
|
|
199
|
+
return {
|
|
200
|
+
"text": text,
|
|
201
|
+
"content_fidelity": (
|
|
202
|
+
"visible_text" if message_type == "interactive" else "structured_text"
|
|
203
|
+
),
|
|
204
|
+
"content_read": True,
|
|
205
|
+
"content_metadata": {},
|
|
206
|
+
"resources": resources,
|
|
207
|
+
"truncated": truncated,
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
text, content_metadata, intrinsic_resource = _metadata_projection(
|
|
211
|
+
message_type,
|
|
212
|
+
content,
|
|
213
|
+
read_image_keys=read_image_key_set,
|
|
214
|
+
)
|
|
215
|
+
if intrinsic_resource is not None and _resource_signature(
|
|
216
|
+
intrinsic_resource
|
|
217
|
+
) not in {_resource_signature(item) for item in resources}:
|
|
218
|
+
resources.append(intrinsic_resource)
|
|
219
|
+
image_read = message_type == "image" and any(
|
|
220
|
+
item.get("type") == "image" and item.get("content_read") is True
|
|
221
|
+
for item in resources
|
|
222
|
+
)
|
|
223
|
+
return {
|
|
224
|
+
"text": text,
|
|
225
|
+
"content_fidelity": "full_multimodal" if image_read else "metadata_only",
|
|
226
|
+
"content_read": image_read,
|
|
227
|
+
"content_metadata": content_metadata,
|
|
228
|
+
"resources": resources,
|
|
229
|
+
"truncated": truncated,
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def _project_merge_forward(message: Any) -> dict[str, Any]:
|
|
234
|
+
"""Bound the SDK's typed tree before using its public text converter.
|
|
235
|
+
|
|
236
|
+
The SDK's item limit applies separately to every nested container and its
|
|
237
|
+
converter can silently skip a malformed child. Validate retained leaves
|
|
238
|
+
individually, then freeze their rendered text in copied SDK items so the
|
|
239
|
+
final SDK layout cannot discard them. No resource here represents read
|
|
240
|
+
bytes, regardless of an outer caller's image-read set.
|
|
241
|
+
"""
|
|
242
|
+
|
|
243
|
+
content = getattr(message, "content", None)
|
|
244
|
+
if not isinstance(content, MergeForwardContent):
|
|
245
|
+
raise HistoricalMessageContractError(
|
|
246
|
+
"合并转发缺少可验证的消息结构,本条消息未执行。"
|
|
247
|
+
)
|
|
248
|
+
_validate_forward_state(content)
|
|
249
|
+
if not content.items:
|
|
250
|
+
raise HistoricalMessageUnavailable(
|
|
251
|
+
"合并转发消息没有可读取的内容,请复制内容后重试。"
|
|
252
|
+
)
|
|
253
|
+
visiting: set[int] = set()
|
|
254
|
+
|
|
255
|
+
def validate_depth(node: MergeForwardContent, depth: int) -> None:
|
|
256
|
+
# Check the returned tree before applying the output item budget: an
|
|
257
|
+
# omitted branch must not hide an SDK/local depth-limit violation.
|
|
258
|
+
if id(node) in visiting:
|
|
259
|
+
raise HistoricalMessageContractError(
|
|
260
|
+
"合并转发包含循环消息结构,本条消息未执行。"
|
|
261
|
+
)
|
|
262
|
+
if depth > _FORWARD_DEPTH_LIMIT or node.error == "max_depth_exceeded":
|
|
263
|
+
raise HistoricalMessageUnavailable(_FORWARD_DEPTH_ERROR)
|
|
264
|
+
if not isinstance(node.items, list):
|
|
265
|
+
return # Retained malformed containers are rejected by prepare.
|
|
266
|
+
visiting.add(id(node))
|
|
267
|
+
for item in node.items:
|
|
268
|
+
child = getattr(item, "content", None)
|
|
269
|
+
if isinstance(child, MergeForwardContent):
|
|
270
|
+
validate_depth(child, depth + 1)
|
|
271
|
+
visiting.remove(id(node))
|
|
272
|
+
|
|
273
|
+
validate_depth(content, 0)
|
|
274
|
+
remaining = _FORWARD_ITEM_LIMIT
|
|
275
|
+
descriptors: list[Any] = []
|
|
276
|
+
|
|
277
|
+
def prepare(node: MergeForwardContent) -> MergeForwardContent:
|
|
278
|
+
nonlocal remaining
|
|
279
|
+
_validate_forward_state(node)
|
|
280
|
+
items: list[MergeForwardItem] = []
|
|
281
|
+
truncated = bool(node.truncated)
|
|
282
|
+
for item in node.items:
|
|
283
|
+
if remaining == 0:
|
|
284
|
+
truncated = True
|
|
285
|
+
break
|
|
286
|
+
remaining -= 1
|
|
287
|
+
if not isinstance(item, MergeForwardItem) or item.content is None:
|
|
288
|
+
raise HistoricalMessageUnavailable(
|
|
289
|
+
"合并转发包含无法读取的子消息,本条消息未执行。"
|
|
290
|
+
)
|
|
291
|
+
_validate_forward_item(item)
|
|
292
|
+
child = item.content
|
|
293
|
+
if isinstance(child, MergeForwardContent):
|
|
294
|
+
prepared_child = prepare(child)
|
|
295
|
+
if not prepared_child.items and not prepared_child.truncated:
|
|
296
|
+
raise HistoricalMessageUnavailable(
|
|
297
|
+
"合并转发包含未展开的子消息,本条消息未执行。"
|
|
298
|
+
)
|
|
299
|
+
truncated |= prepared_child.truncated
|
|
300
|
+
else:
|
|
301
|
+
kind = getattr(child, "kind", None)
|
|
302
|
+
if kind not in SUPPORTED_MESSAGE_TYPES:
|
|
303
|
+
raise UnsupportedHistoricalMessage(
|
|
304
|
+
"合并转发包含不支持的子消息类型,"
|
|
305
|
+
"请拆分后重试。本条消息未执行。"
|
|
306
|
+
)
|
|
307
|
+
if kind == "interactive":
|
|
308
|
+
_validate_interactive_content(child)
|
|
309
|
+
try:
|
|
310
|
+
if isinstance(child, PostContent):
|
|
311
|
+
displayed_post, child_resources = _forward_post_content(child)
|
|
312
|
+
child_text, _ = flatten_content(displayed_post)
|
|
313
|
+
else:
|
|
314
|
+
child_text, child_resources = flatten_content(child)
|
|
315
|
+
except Exception as error:
|
|
316
|
+
raise HistoricalMessageUnavailable(
|
|
317
|
+
"合并转发子消息无法转换为可见内容,本条消息未执行。"
|
|
318
|
+
) from error
|
|
319
|
+
if usable_text(child_text) is None:
|
|
320
|
+
raise HistoricalMessageUnavailable(
|
|
321
|
+
"合并转发子消息没有可读取的内容,本条消息未执行。"
|
|
322
|
+
)
|
|
323
|
+
# Metadata leaves use the existing source-neutral resource
|
|
324
|
+
# description; generated file/image keys stay internal.
|
|
325
|
+
if kind in METADATA_TYPES:
|
|
326
|
+
child_text, _, intrinsic = _metadata_projection(
|
|
327
|
+
kind, child, read_image_keys=frozenset()
|
|
328
|
+
)
|
|
329
|
+
if intrinsic is not None:
|
|
330
|
+
descriptors.append(intrinsic)
|
|
331
|
+
descriptors.extend(child_resources)
|
|
332
|
+
if len(child_text) > _FORWARD_TEXT_LIMIT:
|
|
333
|
+
child_text = child_text[:_FORWARD_TEXT_LIMIT]
|
|
334
|
+
truncated = True
|
|
335
|
+
prepared_child = TextContent(text=child_text)
|
|
336
|
+
items.append(replace(item, content=prepared_child))
|
|
337
|
+
return replace(node, items=items, truncated=truncated)
|
|
338
|
+
|
|
339
|
+
prepared = prepare(content)
|
|
340
|
+
text, _ = flatten_content(prepared)
|
|
341
|
+
text = resolve_mentions(
|
|
342
|
+
text,
|
|
343
|
+
MentionExtraction(mentions={
|
|
344
|
+
mention.key: mention
|
|
345
|
+
for mention in getattr(message, "mentions", ()) or ()
|
|
346
|
+
if isinstance(getattr(mention, "key", None), str)
|
|
347
|
+
and isinstance(getattr(mention, "name", None), str)
|
|
348
|
+
}),
|
|
349
|
+
)
|
|
350
|
+
if usable_text(text) is None:
|
|
351
|
+
raise HistoricalMessageUnavailable(
|
|
352
|
+
"合并转发消息没有可读取的内容,请复制内容后重试。"
|
|
353
|
+
)
|
|
354
|
+
resources = _resource_descriptors_metadata(
|
|
355
|
+
descriptors, read_image_keys=frozenset()
|
|
356
|
+
)
|
|
357
|
+
truncated = prepared.truncated
|
|
358
|
+
|
|
359
|
+
suffix = "\n\n" + _FORWARD_MEDIA_NOTICE
|
|
360
|
+
if len(text) + len(suffix) > _FORWARD_TEXT_LIMIT:
|
|
361
|
+
truncated = True
|
|
362
|
+
if truncated:
|
|
363
|
+
suffix += "\n" + _FORWARD_TRUNCATION_NOTICE
|
|
364
|
+
text = text[: _FORWARD_TEXT_LIMIT - len(suffix)] + suffix
|
|
365
|
+
return {
|
|
366
|
+
"text": text,
|
|
367
|
+
"content_fidelity": "bounded_aggregate",
|
|
368
|
+
"content_read": True,
|
|
369
|
+
"content_metadata": {},
|
|
370
|
+
"resources": resources,
|
|
371
|
+
"truncated": bool(truncated),
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
def _validate_forward_state(content: MergeForwardContent) -> None:
|
|
376
|
+
if content.error == "max_depth_exceeded":
|
|
377
|
+
raise HistoricalMessageUnavailable(_FORWARD_DEPTH_ERROR)
|
|
378
|
+
if content.loading or content.error:
|
|
379
|
+
raise HistoricalMessageUnavailable(
|
|
380
|
+
"合并转发尚未完整读取,或其中消息读取失败,本条消息未执行;请重试。"
|
|
381
|
+
)
|
|
382
|
+
if not isinstance(content.items, list):
|
|
383
|
+
raise HistoricalMessageContractError(
|
|
384
|
+
"合并转发子消息结构无效,本条消息未执行。"
|
|
385
|
+
)
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
def _forward_post_content(content: PostContent) -> tuple[PostContent, list[Any]]:
|
|
389
|
+
"""Mask only SDK-generated resource targets in a copied visible post AST.
|
|
390
|
+
|
|
391
|
+
Match the pinned SDK's first-locale/content_v2 selection and top-level
|
|
392
|
+
files zone. Markdown targets use the existing image-marker boundary;
|
|
393
|
+
text, fenced code, titles, and other user-authored fields remain literal.
|
|
394
|
+
Text and resource extraction share the same visible locale/body. The SDK
|
|
395
|
+
reads resources only from ``content``, so expose the selected paragraphs
|
|
396
|
+
there on a copy before collecting their original keys. Placeholder targets
|
|
397
|
+
are display-only and can never be downloaded.
|
|
398
|
+
"""
|
|
399
|
+
|
|
400
|
+
post = content.post
|
|
401
|
+
if not isinstance(post, dict) or not post:
|
|
402
|
+
return content, flatten_content(content)[1]
|
|
403
|
+
# image_inputs depends on this module's shared type gate. Import its
|
|
404
|
+
# existing marker helper only during preparation to avoid an import cycle
|
|
405
|
+
# while preserving the same fenced-code semantics as native image input.
|
|
406
|
+
from .image_inputs import localize_image_markers
|
|
407
|
+
|
|
408
|
+
document = post if "content" in post else next(
|
|
409
|
+
(value for value in post.values() if isinstance(value, dict)), None,
|
|
410
|
+
)
|
|
411
|
+
visible_post = dict(document) if document is not None else {}
|
|
412
|
+
if document is not None:
|
|
413
|
+
content_v2 = document.get("content_v2")
|
|
414
|
+
visible_post["content"] = (
|
|
415
|
+
content_v2
|
|
416
|
+
if isinstance(content_v2, list) and content_v2
|
|
417
|
+
else document.get("content") or []
|
|
418
|
+
)
|
|
419
|
+
visible_post.pop("content_v2", None)
|
|
420
|
+
# Only the top-level attachment zone is visible, not a locale's files field.
|
|
421
|
+
visible_post.pop("files", None)
|
|
422
|
+
if "files" in post:
|
|
423
|
+
visible_post["files"] = post["files"]
|
|
424
|
+
visible_content = replace(content, post=visible_post)
|
|
425
|
+
_, resources = flatten_content(visible_content)
|
|
426
|
+
marker_refs = {
|
|
427
|
+
("current_message", "forward-post", resource.file_key): "unread-image"
|
|
428
|
+
for resource in resources
|
|
429
|
+
if getattr(resource, "type", None) == "image"
|
|
430
|
+
and _nonempty_string(getattr(resource, "file_key", None)) is not None
|
|
431
|
+
}
|
|
432
|
+
replacement = dict(visible_post)
|
|
433
|
+
changed = False
|
|
434
|
+
if document is not None:
|
|
435
|
+
paragraphs = visible_post.get("content")
|
|
436
|
+
if isinstance(paragraphs, list):
|
|
437
|
+
copied_paragraphs = []
|
|
438
|
+
body_changed = False
|
|
439
|
+
for paragraph in paragraphs:
|
|
440
|
+
if not isinstance(paragraph, list):
|
|
441
|
+
copied_paragraphs.append(paragraph)
|
|
442
|
+
continue
|
|
443
|
+
copied_nodes = []
|
|
444
|
+
for node in paragraph:
|
|
445
|
+
copied = node
|
|
446
|
+
if isinstance(node, dict):
|
|
447
|
+
tag = node.get("tag")
|
|
448
|
+
if tag == "md" and isinstance(node.get("text"), str):
|
|
449
|
+
rendered = localize_image_markers(
|
|
450
|
+
node["text"],
|
|
451
|
+
source="current_message",
|
|
452
|
+
message_id="forward-post",
|
|
453
|
+
image_prompt_refs=marker_refs,
|
|
454
|
+
)
|
|
455
|
+
if rendered != node["text"]:
|
|
456
|
+
copied = {**node, "text": rendered}
|
|
457
|
+
body_changed = True
|
|
458
|
+
fields = (
|
|
459
|
+
("image_key",)
|
|
460
|
+
if tag == "img"
|
|
461
|
+
else ("file_key", "image_key")
|
|
462
|
+
if tag == "media"
|
|
463
|
+
else ("file_key",)
|
|
464
|
+
if tag in ("audio", "file")
|
|
465
|
+
else ()
|
|
466
|
+
)
|
|
467
|
+
for field in fields:
|
|
468
|
+
if _nonempty_string(node.get(field)) is not None:
|
|
469
|
+
copied = dict(copied)
|
|
470
|
+
copied[field] = (
|
|
471
|
+
"unread-image" if field == "image_key"
|
|
472
|
+
else f"unread-{tag}"
|
|
473
|
+
)
|
|
474
|
+
body_changed = True
|
|
475
|
+
copied_nodes.append(copied)
|
|
476
|
+
copied_paragraphs.append(copied_nodes)
|
|
477
|
+
if body_changed:
|
|
478
|
+
replacement["content"] = copied_paragraphs
|
|
479
|
+
changed = True
|
|
480
|
+
files = post.get("files")
|
|
481
|
+
if isinstance(files, list):
|
|
482
|
+
copied_files = []
|
|
483
|
+
for item in files:
|
|
484
|
+
if isinstance(item, dict) and _nonempty_string(item.get("file_key")):
|
|
485
|
+
copied_files.append({
|
|
486
|
+
**item,
|
|
487
|
+
"file_key": (
|
|
488
|
+
"unread-folder" if item.get("is_folder") is True
|
|
489
|
+
else "unread-file"
|
|
490
|
+
),
|
|
491
|
+
})
|
|
492
|
+
changed = True
|
|
493
|
+
else:
|
|
494
|
+
copied_files.append(item)
|
|
495
|
+
if changed:
|
|
496
|
+
replacement["files"] = copied_files
|
|
497
|
+
return (
|
|
498
|
+
replace(visible_content, post=replacement) if changed else visible_content,
|
|
499
|
+
resources,
|
|
500
|
+
)
|
|
501
|
+
|
|
502
|
+
|
|
503
|
+
def _validate_forward_item(item: MergeForwardItem) -> None:
|
|
504
|
+
# The final SDK renderer isolates exceptions by silently dropping items.
|
|
505
|
+
# Give it only the public dataclass's scalar shapes, including a bounded
|
|
506
|
+
# timestamp; leaf conversion alone cannot prove its header will render.
|
|
507
|
+
valid_sender = all(
|
|
508
|
+
value is None or isinstance(value, str)
|
|
509
|
+
for value in (item.sender_name, item.sender_open_id)
|
|
510
|
+
)
|
|
511
|
+
valid_time = item.create_time is None or (
|
|
512
|
+
type(item.create_time) is int
|
|
513
|
+
and 0 <= item.create_time <= 253_402_300_799_999
|
|
514
|
+
)
|
|
515
|
+
if not isinstance(item.message_id, str) or not valid_sender or not valid_time:
|
|
516
|
+
raise HistoricalMessageContractError(
|
|
517
|
+
"合并转发子消息的来源或时间格式无效,本条消息未执行。"
|
|
518
|
+
)
|
|
519
|
+
|
|
520
|
+
|
|
521
|
+
def validate_interactive_version(content: Any) -> None:
|
|
522
|
+
"""Reject legacy cards, including v1 headers misclassified by SDK parsing."""
|
|
523
|
+
|
|
524
|
+
card = getattr(content, "card", None)
|
|
525
|
+
legacy_shape = (
|
|
526
|
+
isinstance(card, dict)
|
|
527
|
+
and "schema" not in card
|
|
528
|
+
and "body" not in card
|
|
529
|
+
and any(key in card for key in ("elements", "i18n_elements", "config", "card"))
|
|
530
|
+
)
|
|
531
|
+
if getattr(content, "card_version", None) == "v1" or legacy_shape:
|
|
532
|
+
raise UnsupportedHistoricalMessage(
|
|
533
|
+
"不支持飞书 1.0 卡片,请提供 2.0 卡片或复制可见正文。本条消息未执行。"
|
|
534
|
+
)
|
|
535
|
+
|
|
536
|
+
|
|
537
|
+
def _validate_interactive_content(content: Any) -> None:
|
|
538
|
+
"""Reject the SDK's proven non-visible text leak without parsing cards.
|
|
539
|
+
|
|
540
|
+
SDK 1.4.0 visits arbitrary dictionary values beneath header/body. A
|
|
541
|
+
markdown/plain_text node inside an interaction payload therefore leaks
|
|
542
|
+
into its public text. Normal scalar values and visible button labels are
|
|
543
|
+
fine. This gate can be removed once the SDK excludes those payloads.
|
|
544
|
+
"""
|
|
545
|
+
|
|
546
|
+
validate_interactive_version(content)
|
|
547
|
+
card = getattr(content, "card", None)
|
|
548
|
+
if not isinstance(card, dict):
|
|
549
|
+
return
|
|
550
|
+
header = card.get("header")
|
|
551
|
+
roots = [card.get("body")]
|
|
552
|
+
if isinstance(header, dict):
|
|
553
|
+
roots.extend((header.get("title"), header.get("subtitle")))
|
|
554
|
+
stack = [(node, 0, False) for node in roots]
|
|
555
|
+
scheduled = len(stack)
|
|
556
|
+
text_size = 0
|
|
557
|
+
while stack:
|
|
558
|
+
node, depth, hidden = stack.pop()
|
|
559
|
+
if depth > _CARD_DEPTH_LIMIT:
|
|
560
|
+
raise HistoricalMessageUnavailable(
|
|
561
|
+
"应用卡片结构超过可验证范围,本条消息未执行。"
|
|
562
|
+
)
|
|
563
|
+
if isinstance(node, str):
|
|
564
|
+
text_size += len(node)
|
|
565
|
+
elif isinstance(node, dict):
|
|
566
|
+
tag = node.get("tag")
|
|
567
|
+
if tag == "plain_text" or tag == "markdown":
|
|
568
|
+
text = node.get("content")
|
|
569
|
+
if hidden and text:
|
|
570
|
+
raise HistoricalMessageUnavailable(
|
|
571
|
+
"应用卡片的 SDK 转换混入了非可见交互内容,"
|
|
572
|
+
"请复制可见正文后重试。本条消息未执行。"
|
|
573
|
+
)
|
|
574
|
+
# The SDK stops traversal at a recognized text node too.
|
|
575
|
+
if isinstance(text, str):
|
|
576
|
+
text_size += len(text)
|
|
577
|
+
else:
|
|
578
|
+
for key, value in node.items():
|
|
579
|
+
scheduled += 1
|
|
580
|
+
if scheduled > _CARD_NODE_LIMIT:
|
|
581
|
+
raise HistoricalMessageUnavailable(
|
|
582
|
+
"应用卡片结构超过可验证范围,本条消息未执行。"
|
|
583
|
+
)
|
|
584
|
+
stack.append(
|
|
585
|
+
(value, depth + 1, hidden or key in _CARD_HIDDEN_FIELDS)
|
|
586
|
+
)
|
|
587
|
+
elif isinstance(node, list):
|
|
588
|
+
scheduled += len(node)
|
|
589
|
+
if scheduled > _CARD_NODE_LIMIT:
|
|
590
|
+
raise HistoricalMessageUnavailable(
|
|
591
|
+
"应用卡片结构超过可验证范围,本条消息未执行。"
|
|
592
|
+
)
|
|
593
|
+
stack.extend((value, depth + 1, hidden) for value in node)
|
|
594
|
+
if text_size > _CARD_TEXT_LIMIT:
|
|
595
|
+
raise HistoricalMessageUnavailable(
|
|
596
|
+
"应用卡片内容超过可验证范围,本条消息未执行。"
|
|
597
|
+
)
|
|
598
|
+
|
|
599
|
+
|
|
600
|
+
def _metadata_projection(
|
|
601
|
+
message_type: str,
|
|
602
|
+
content: Any,
|
|
603
|
+
*,
|
|
604
|
+
read_image_keys: frozenset[str],
|
|
605
|
+
) -> tuple[str, dict[str, Any], dict[str, Any] | None]:
|
|
606
|
+
if message_type == "image":
|
|
607
|
+
key = _nonempty_string(getattr(content, "image_key", None))
|
|
608
|
+
image_read = key is not None and key in read_image_keys
|
|
609
|
+
resource = _resource_item("image", file_key=key, content_read=image_read)
|
|
610
|
+
text = (
|
|
611
|
+
"引用了一张图片;图片像素已作为原生视觉输入提供。"
|
|
612
|
+
if image_read
|
|
613
|
+
else "引用了一张图片;当前版本未读取图片像素内容。"
|
|
614
|
+
)
|
|
615
|
+
return text, {}, resource
|
|
616
|
+
if message_type == "file":
|
|
617
|
+
name = _nonempty_string(getattr(content, "file_name", None))
|
|
618
|
+
key = _nonempty_string(getattr(content, "file_key", None))
|
|
619
|
+
suffix = f",文件名:{name}" if name else ""
|
|
620
|
+
return (
|
|
621
|
+
f"引用了一个文件{suffix};当前版本未读取文件正文。",
|
|
622
|
+
{},
|
|
623
|
+
_resource_item("file", file_key=key, file_name=name),
|
|
624
|
+
)
|
|
625
|
+
if message_type == "folder":
|
|
626
|
+
name = _nonempty_string(getattr(content, "file_name", None))
|
|
627
|
+
key = _nonempty_string(getattr(content, "file_key", None))
|
|
628
|
+
suffix = f",文件夹名:{name}" if name else ""
|
|
629
|
+
return (
|
|
630
|
+
f"引用了一个文件夹{suffix};当前版本未读取其中内容。",
|
|
631
|
+
{},
|
|
632
|
+
_resource_item("folder", file_key=key, file_name=name),
|
|
633
|
+
)
|
|
634
|
+
if message_type == "audio":
|
|
635
|
+
key = _nonempty_string(getattr(content, "file_key", None))
|
|
636
|
+
duration = _positive_int(getattr(content, "duration_ms", None))
|
|
637
|
+
suffix = f",时长 {duration}ms" if duration is not None else ""
|
|
638
|
+
return (
|
|
639
|
+
f"引用了一段语音{suffix};当前版本未转写音频。",
|
|
640
|
+
{},
|
|
641
|
+
_resource_item("audio", file_key=key, duration_ms=duration),
|
|
642
|
+
)
|
|
643
|
+
if message_type == "media":
|
|
644
|
+
key = _nonempty_string(getattr(content, "file_key", None))
|
|
645
|
+
cover = _nonempty_string(getattr(content, "image_key", None))
|
|
646
|
+
name = _nonempty_string(getattr(content, "file_name", None))
|
|
647
|
+
duration = _positive_int(getattr(content, "duration_ms", None))
|
|
648
|
+
details = []
|
|
649
|
+
if name:
|
|
650
|
+
details.append(f"文件名:{name}")
|
|
651
|
+
if duration is not None:
|
|
652
|
+
details.append(f"时长 {duration}ms")
|
|
653
|
+
suffix = f",{','.join(details)}" if details else ""
|
|
654
|
+
return (
|
|
655
|
+
f"引用了一个视频{suffix};当前版本未读取视频内容。",
|
|
656
|
+
{},
|
|
657
|
+
_resource_item(
|
|
658
|
+
"video",
|
|
659
|
+
file_key=key,
|
|
660
|
+
file_name=name,
|
|
661
|
+
duration_ms=duration,
|
|
662
|
+
cover_image_key=cover,
|
|
663
|
+
),
|
|
664
|
+
)
|
|
665
|
+
if message_type == "sticker":
|
|
666
|
+
key = _nonempty_string(getattr(content, "file_key", None))
|
|
667
|
+
return (
|
|
668
|
+
"引用了一个表情包;当前版本未读取表情内容。",
|
|
669
|
+
{},
|
|
670
|
+
_resource_item("sticker", file_key=key),
|
|
671
|
+
)
|
|
672
|
+
if message_type == "share_chat":
|
|
673
|
+
chat_id = _nonempty_string(getattr(content, "chat_id", None))
|
|
674
|
+
return (
|
|
675
|
+
"引用了一个群名片;未额外读取群信息。",
|
|
676
|
+
{"chat_id": chat_id},
|
|
677
|
+
None,
|
|
678
|
+
)
|
|
679
|
+
if message_type == "share_user":
|
|
680
|
+
user_id = _nonempty_string(getattr(content, "user_id", None))
|
|
681
|
+
return (
|
|
682
|
+
"引用了一个个人名片;未额外读取联系人信息。",
|
|
683
|
+
{"user_id": user_id},
|
|
684
|
+
None,
|
|
685
|
+
)
|
|
686
|
+
raise UnsupportedHistoricalMessage(
|
|
687
|
+
f"暂不支持这种历史消息类型({message_type}),"
|
|
688
|
+
"本条消息未执行。"
|
|
689
|
+
)
|
|
690
|
+
|
|
691
|
+
|
|
692
|
+
def _resource_metadata(
|
|
693
|
+
message: Any,
|
|
694
|
+
*,
|
|
695
|
+
read_image_keys: frozenset[str],
|
|
696
|
+
) -> list[dict[str, Any]]:
|
|
697
|
+
return _resource_descriptors_metadata(
|
|
698
|
+
getattr(message, "resources", None) or (),
|
|
699
|
+
read_image_keys=read_image_keys,
|
|
700
|
+
)
|
|
701
|
+
|
|
702
|
+
|
|
703
|
+
def _resource_descriptors_metadata(
|
|
704
|
+
descriptors: Any,
|
|
705
|
+
*,
|
|
706
|
+
read_image_keys: frozenset[str],
|
|
707
|
+
) -> list[dict[str, Any]]:
|
|
708
|
+
result: list[dict[str, Any]] = []
|
|
709
|
+
seen: set[tuple[Any, ...]] = set()
|
|
710
|
+
for resource in descriptors:
|
|
711
|
+
if isinstance(resource, dict):
|
|
712
|
+
item = dict(resource)
|
|
713
|
+
signature = _resource_signature(item)
|
|
714
|
+
if signature not in seen:
|
|
715
|
+
seen.add(signature)
|
|
716
|
+
result.append(item)
|
|
717
|
+
continue
|
|
718
|
+
resource_type = _nonempty_string(getattr(resource, "type", None))
|
|
719
|
+
if resource_type is None:
|
|
720
|
+
continue
|
|
721
|
+
item = _resource_item(
|
|
722
|
+
resource_type,
|
|
723
|
+
file_key=_nonempty_string(getattr(resource, "file_key", None)),
|
|
724
|
+
file_name=_nonempty_string(getattr(resource, "file_name", None)),
|
|
725
|
+
duration_ms=_positive_int(getattr(resource, "duration_ms", None)),
|
|
726
|
+
cover_image_key=_nonempty_string(
|
|
727
|
+
getattr(resource, "cover_image_key", None)
|
|
728
|
+
),
|
|
729
|
+
content_read=(
|
|
730
|
+
resource_type == "image"
|
|
731
|
+
and _nonempty_string(getattr(resource, "file_key", None))
|
|
732
|
+
in read_image_keys
|
|
733
|
+
),
|
|
734
|
+
)
|
|
735
|
+
signature = _resource_signature(item)
|
|
736
|
+
if signature in seen:
|
|
737
|
+
continue
|
|
738
|
+
seen.add(signature)
|
|
739
|
+
result.append(item)
|
|
740
|
+
return result
|
|
741
|
+
|
|
742
|
+
|
|
743
|
+
def _resource_item(
|
|
744
|
+
resource_type: str,
|
|
745
|
+
*,
|
|
746
|
+
file_key: str | None = None,
|
|
747
|
+
file_name: str | None = None,
|
|
748
|
+
duration_ms: int | None = None,
|
|
749
|
+
cover_image_key: str | None = None,
|
|
750
|
+
content_read: bool = False,
|
|
751
|
+
) -> dict[str, Any]:
|
|
752
|
+
result: dict[str, Any] = {"type": resource_type, "content_read": content_read}
|
|
753
|
+
if file_key is not None:
|
|
754
|
+
result["file_key"] = file_key
|
|
755
|
+
if file_name is not None:
|
|
756
|
+
result["file_name"] = file_name
|
|
757
|
+
if duration_ms is not None:
|
|
758
|
+
result["duration_ms"] = duration_ms
|
|
759
|
+
if cover_image_key is not None:
|
|
760
|
+
result["cover_image_key"] = cover_image_key
|
|
761
|
+
return result
|
|
762
|
+
|
|
763
|
+
|
|
764
|
+
def _resource_signature(resource: dict[str, Any]) -> tuple[Any, ...]:
|
|
765
|
+
return (
|
|
766
|
+
resource.get("type"),
|
|
767
|
+
resource.get("file_key"),
|
|
768
|
+
resource.get("file_name"),
|
|
769
|
+
resource.get("duration_ms"),
|
|
770
|
+
resource.get("cover_image_key"),
|
|
771
|
+
)
|
|
772
|
+
|
|
773
|
+
|
|
774
|
+
def usable_text(value: Any) -> str | None:
|
|
775
|
+
if not isinstance(value, str):
|
|
776
|
+
return None
|
|
777
|
+
text = value.strip()
|
|
778
|
+
return None if text in PLACEHOLDER_TEXT else text
|
|
779
|
+
|
|
780
|
+
|
|
781
|
+
def _positive_int(value: Any) -> int | None:
|
|
782
|
+
return value if isinstance(value, int) and value >= 0 else None
|
|
783
|
+
|
|
784
|
+
|
|
785
|
+
def _nonempty_string(value: Any) -> str | None:
|
|
786
|
+
return value.strip() if isinstance(value, str) and value.strip() else None
|