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.
Files changed (112) hide show
  1. netizen_cli/__init__.py +3 -0
  2. netizen_cli/__main__.py +4 -0
  3. netizen_cli/admin/__init__.py +1 -0
  4. netizen_cli/admin/auth.py +928 -0
  5. netizen_cli/admin/errors.py +9 -0
  6. netizen_cli/admin/port_config.py +115 -0
  7. netizen_cli/admin/presentation.py +257 -0
  8. netizen_cli/admin/queries.py +337 -0
  9. netizen_cli/admin/static/admin.css +260 -0
  10. netizen_cli/admin/static/admin.js +2898 -0
  11. netizen_cli/admin/static/index.html +327 -0
  12. netizen_cli/admin/transport.py +935 -0
  13. netizen_cli/admin/web.py +2717 -0
  14. netizen_cli/bindings.py +3215 -0
  15. netizen_cli/builtin_skills.py +93 -0
  16. netizen_cli/cards/__init__.py +105 -0
  17. netizen_cli/cards/callbacks.py +565 -0
  18. netizen_cli/cards/controls.py +2273 -0
  19. netizen_cli/cards/defaults.py +213 -0
  20. netizen_cli/cards/model_info.py +80 -0
  21. netizen_cli/cards/questions.py +220 -0
  22. netizen_cli/cards/reply.py +2247 -0
  23. netizen_cli/cards/scheduled.py +836 -0
  24. netizen_cli/channel/__init__.py +1 -0
  25. netizen_cli/channel/completion_mentions.py +60 -0
  26. netizen_cli/channel/input_preparation.py +644 -0
  27. netizen_cli/channel/messages.py +57 -0
  28. netizen_cli/channel/ports.py +52 -0
  29. netizen_cli/channel/question_inputs.py +51 -0
  30. netizen_cli/channel/reactions.py +293 -0
  31. netizen_cli/channel/reply_presenter.py +1505 -0
  32. netizen_cli/channel/topics.py +70 -0
  33. netizen_cli/channel_app.py +6593 -0
  34. netizen_cli/cli.py +287 -0
  35. netizen_cli/cli_data.py +536 -0
  36. netizen_cli/cli_packages.py +526 -0
  37. netizen_cli/cli_services.py +651 -0
  38. netizen_cli/cli_setup.py +242 -0
  39. netizen_cli/cli_update.py +303 -0
  40. netizen_cli/cli_update_restore.py +53 -0
  41. netizen_cli/cli_update_worker.py +333 -0
  42. netizen_cli/codex_runtime.py +7125 -0
  43. netizen_cli/completion_mention.py +16 -0
  44. netizen_cli/database_migrations.py +218 -0
  45. netizen_cli/defaults/__init__.py +5 -0
  46. netizen_cli/defaults/models.py +39 -0
  47. netizen_cli/defaults/service.py +232 -0
  48. netizen_cli/defaults/store.py +260 -0
  49. netizen_cli/deployment/__init__.py +1 -0
  50. netizen_cli/deployment/restart_worker.py +134 -0
  51. netizen_cli/deployment/update_executor.py +258 -0
  52. netizen_cli/deployment/update_protocol.py +281 -0
  53. netizen_cli/domain.py +416 -0
  54. netizen_cli/error_messages.py +124 -0
  55. netizen_cli/experience.py +531 -0
  56. netizen_cli/feishu_app_onboarding.py +187 -0
  57. netizen_cli/feishu_app_permissions.py +123 -0
  58. netizen_cli/git_status.py +63 -0
  59. netizen_cli/image_inputs.py +579 -0
  60. netizen_cli/instance.py +84 -0
  61. netizen_cli/lark_app.py +125 -0
  62. netizen_cli/main.py +903 -0
  63. netizen_cli/management/__init__.py +83 -0
  64. netizen_cli/management/blocking_io.py +352 -0
  65. netizen_cli/management/chat_labels.py +266 -0
  66. netizen_cli/management/coordination.py +32 -0
  67. netizen_cli/management/service.py +2187 -0
  68. netizen_cli/management/updates.py +214 -0
  69. netizen_cli/markdown_images.py +78 -0
  70. netizen_cli/message_content.py +786 -0
  71. netizen_cli/message_history.py +643 -0
  72. netizen_cli/message_preparation.py +60 -0
  73. netizen_cli/message_projection.py +923 -0
  74. netizen_cli/migrations/__init__.py +1 -0
  75. netizen_cli/migrations/schema.py +103 -0
  76. netizen_cli/migrations/v14.py +438 -0
  77. netizen_cli/model_settings.py +269 -0
  78. netizen_cli/package_resources.py +22 -0
  79. netizen_cli/projects.py +327 -0
  80. netizen_cli/prompt_projection.py +327 -0
  81. netizen_cli/quoted_context.py +312 -0
  82. netizen_cli/resources/config.example.yaml +35 -0
  83. netizen_cli/resources/skills/netizen-lark/SKILL.md +64 -0
  84. netizen_cli/resources/skills/netizen-user-guide/SKILL.md +37 -0
  85. netizen_cli/resources/skills/netizen-user-guide/references/user-guide.md +842 -0
  86. netizen_cli/result_images.py +123 -0
  87. netizen_cli/runtime/__init__.py +1 -0
  88. netizen_cli/runtime/contracts.py +792 -0
  89. netizen_cli/runtime/name_writes.py +67 -0
  90. netizen_cli/runtime/thread_naming.py +451 -0
  91. netizen_cli/schedules/__init__.py +1 -0
  92. netizen_cli/schedules/mcp.py +535 -0
  93. netizen_cli/schedules/models.py +394 -0
  94. netizen_cli/schedules/scheduler.py +374 -0
  95. netizen_cli/schedules/service.py +766 -0
  96. netizen_cli/schedules/store.py +771 -0
  97. netizen_cli/sdk_gap_adapter.py +1151 -0
  98. netizen_cli/service_launcher.py +583 -0
  99. netizen_cli/session_settings.py +126 -0
  100. netizen_cli/settings.py +216 -0
  101. netizen_cli/skill_references.py +40 -0
  102. netizen_cli/terminal_cleanup.py +155 -0
  103. netizen_cli/turn_activity.py +688 -0
  104. netizen_cli/turn_files.py +812 -0
  105. netizen_cli/turn_patch_children.py +254 -0
  106. netizen_cli/turn_plan_observer.py +315 -0
  107. netizen_cli/user_questions.py +106 -0
  108. netizen_cli-0.10.0.dist-info/METADATA +18 -0
  109. netizen_cli-0.10.0.dist-info/RECORD +112 -0
  110. netizen_cli-0.10.0.dist-info/WHEEL +5 -0
  111. netizen_cli-0.10.0.dist-info/entry_points.txt +2 -0
  112. 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