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,579 @@
1
+ """Bounded Feishu image resources projected as native Codex image inputs.
2
+
3
+ Only ordinary image messages and images embedded in ``post`` messages enter
4
+ this boundary. Card, merge-forward, file, audio, video, and sticker resources
5
+ remain outside the Pilot image scope.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import asyncio
11
+ import base64
12
+ import json
13
+ import re
14
+ from collections.abc import Mapping, Sequence
15
+ from dataclasses import dataclass
16
+ from types import MappingProxyType
17
+ from typing import Any, Literal, Protocol
18
+
19
+ from openai_codex import ImageInput, TextInput
20
+
21
+ from .message_content import MATERIAL_MESSAGE_TYPES, normalized_content_type
22
+
23
+
24
+ _SUPPORTED_MESSAGE_TYPES = frozenset({"image", "post"})
25
+ _IMAGE_MARKER_RE = re.compile(r"!\[[^\]]*\]\(([^)]+)\)")
26
+ _IMAGE_FETCH_TIMEOUT_SECONDS = 10.0
27
+ _IMAGE_TOTAL_FETCH_TIMEOUT_SECONDS = 60.0
28
+ _IMAGE_MAX_COUNT = 20
29
+ _IMAGE_MAX_BYTES = 20 * 1024 * 1024
30
+ _IMAGE_TOTAL_MAX_BYTES = 50 * 1024 * 1024
31
+
32
+
33
+ class ImageInputError(RuntimeError):
34
+ """An image-specific failure that is safe to show to the user."""
35
+
36
+
37
+ class UnsupportedPromptMedia(ImageInputError):
38
+ pass
39
+
40
+
41
+ class ImageInputUnavailable(ImageInputError):
42
+ pass
43
+
44
+
45
+ class ImageInputContractError(ImageInputError):
46
+ pass
47
+
48
+
49
+ ImageSource = Literal[
50
+ "supplemental_message",
51
+ "quoted_message",
52
+ "current_message",
53
+ ]
54
+ _IMAGE_SOURCE_ORDER: tuple[ImageSource, ...] = (
55
+ "supplemental_message",
56
+ "quoted_message",
57
+ "current_message",
58
+ )
59
+
60
+
61
+ @dataclass(frozen=True, slots=True)
62
+ class ImageReference:
63
+ source: ImageSource
64
+ message_id: str
65
+ file_key: str
66
+
67
+
68
+ @dataclass(frozen=True, slots=True)
69
+ class PreparedImage:
70
+ reference: ImageReference
71
+ mime_type: str
72
+ size_bytes: int
73
+ data_url: str
74
+
75
+
76
+ ImagePromptReferenceKey = tuple[ImageSource, str, str]
77
+ ImagePromptReferences = Mapping[ImagePromptReferenceKey, str]
78
+
79
+
80
+ class ImageDownloadChannel(Protocol):
81
+ async def download_resource(
82
+ self,
83
+ file_key: str,
84
+ resource_type: str = "image",
85
+ message_id: str | None = None,
86
+ ) -> bytes | None: ...
87
+
88
+
89
+ def current_message_image_references(message: Any) -> tuple[ImageReference, ...]:
90
+ """Validate current prompt media and return its ordinary image refs.
91
+
92
+ Current messages stay strict: text cannot carry resources, and image/post
93
+ prompts cannot silently drop non-image resources. Quoted messages retain
94
+ their existing per-type projection and call :func:`image_references` only
95
+ to add supported pixels.
96
+ """
97
+
98
+ message_type = normalized_message_type(message)
99
+ resources = tuple(getattr(message, "resources", None) or ())
100
+ if message_type in MATERIAL_MESSAGE_TYPES:
101
+ # Material containers are projected by the shared content layer. Their
102
+ # resource descriptors are not proof of readable child image pixels.
103
+ return ()
104
+ if message_type == "text":
105
+ if resources:
106
+ raise UnsupportedPromptMedia(
107
+ "当前消息包含暂不支持的附件;目前仅支持普通图片和富文本图片。"
108
+ )
109
+ return ()
110
+ if message_type not in _SUPPORTED_MESSAGE_TYPES:
111
+ raise UnsupportedPromptMedia(
112
+ f"暂不支持这种消息类型({message_type or 'unknown'});"
113
+ "目前支持文本、普通图片、富文本图片,以及卡片和合并转发材料。"
114
+ )
115
+ if message_type == "post":
116
+ elements = _selected_post_elements(message)
117
+ visible_types = _post_visible_resource_types(elements or ())
118
+ has_top_level_attachments = _post_has_top_level_attachments(message)
119
+ has_unsupported_visible_resource = any(
120
+ resource_type != "image" for resource_type in visible_types
121
+ )
122
+ # A missing typed post AST is a contract gap, so retain descriptors as
123
+ # a conservative fallback for rejecting unsupported attachments. If
124
+ # the AST exists, it is authoritative for the same document/version
125
+ # that the SDK rendered; descriptors can belong to a hidden locale or
126
+ # the unselected content-v1 variant in SDK 1.4.0. Top-level post.files
127
+ # is outside that locale AST and is rejected independently above.
128
+ has_unsupported_fallback_resource = elements is None and any(
129
+ _resource_type(resource) != "image" for resource in resources
130
+ )
131
+ if (
132
+ has_top_level_attachments
133
+ or has_unsupported_visible_resource
134
+ or has_unsupported_fallback_resource
135
+ ):
136
+ raise UnsupportedPromptMedia(
137
+ "当前消息还包含暂不支持的附件;目前仅支持普通图片和富文本图片。"
138
+ )
139
+ elif any(_resource_type(resource) != "image" for resource in resources):
140
+ raise UnsupportedPromptMedia(
141
+ "当前消息还包含暂不支持的附件;目前仅支持普通图片和富文本图片。"
142
+ )
143
+ return image_references(message, source="current_message")
144
+
145
+
146
+ def image_references(
147
+ message: Any,
148
+ *,
149
+ source: ImageSource,
150
+ ) -> tuple[ImageReference, ...]:
151
+ """Return ordered, deduplicated image refs for ``image``/``post`` only."""
152
+
153
+ message_type = normalized_message_type(message)
154
+ if message_type not in _SUPPORTED_MESSAGE_TYPES:
155
+ return ()
156
+ message_id = _message_id(message)
157
+ if not message_id:
158
+ raise ImageInputContractError(
159
+ "图片消息缺少可验证的消息 ID,本条消息未执行。"
160
+ )
161
+
162
+ resource_keys: list[str] = []
163
+ resource_seen: set[str] = set()
164
+ for resource in getattr(message, "resources", None) or ():
165
+ if _resource_type(resource) != "image":
166
+ continue
167
+ key = _nonempty_string(getattr(resource, "file_key", None))
168
+ if key is not None and key not in resource_seen:
169
+ resource_seen.add(key)
170
+ resource_keys.append(key)
171
+
172
+ if message_type == "post":
173
+ # The typed PostContent AST is the only reliable way to distinguish a
174
+ # real image node from a literal Markdown-looking string in a text or
175
+ # code node. Match SDK 1.4.0's public rendering choice: first locale,
176
+ # non-empty content_v2 before content. This also avoids descriptors
177
+ # from hidden locales or an unselected content-v1 variant.
178
+ elements = _selected_post_elements(message)
179
+ if elements is None:
180
+ if not resource_keys:
181
+ return ()
182
+ raise ImageInputContractError(
183
+ "Channel SDK 没有提供可验证的富文本结构,"
184
+ "本条消息未执行;请联系维护者检查 SDK 兼容性。"
185
+ )
186
+ keys = _deduplicated(_post_image_keys(elements))
187
+ return tuple(
188
+ ImageReference(
189
+ source=source,
190
+ message_id=message_id,
191
+ file_key=key,
192
+ )
193
+ for key in keys
194
+ )
195
+
196
+ if message_type == "image":
197
+ content = getattr(message, "content", None)
198
+ intrinsic_key = _nonempty_string(getattr(content, "image_key", None))
199
+ if intrinsic_key is not None:
200
+ if any(key != intrinsic_key for key in resource_keys):
201
+ raise ImageInputContractError(
202
+ "Channel SDK 返回了相互冲突的普通图片资源,"
203
+ "本条消息未执行;请联系维护者检查 SDK 兼容性。"
204
+ )
205
+ keys = [intrinsic_key]
206
+ else:
207
+ keys = list(resource_keys)
208
+ if len(keys) > 1:
209
+ raise ImageInputContractError(
210
+ "普通图片消息包含多个相互冲突的资源,"
211
+ "本条消息未执行;请联系维护者检查 SDK 兼容性。"
212
+ )
213
+ if not keys:
214
+ raise ImageInputContractError(
215
+ "普通图片消息没有可验证的图片资源,本条消息未执行。"
216
+ )
217
+
218
+ return tuple(
219
+ ImageReference(source=source, message_id=message_id, file_key=key)
220
+ for key in keys
221
+ )
222
+
223
+
224
+ async def prepare_images(
225
+ channel: ImageDownloadChannel,
226
+ references: tuple[ImageReference, ...],
227
+ *,
228
+ max_count: int = _IMAGE_MAX_COUNT,
229
+ max_image_bytes: int = _IMAGE_MAX_BYTES,
230
+ max_total_bytes: int = _IMAGE_TOTAL_MAX_BYTES,
231
+ fetch_timeout_seconds: float = _IMAGE_FETCH_TIMEOUT_SECONDS,
232
+ total_timeout_seconds: float = _IMAGE_TOTAL_FETCH_TIMEOUT_SECONDS,
233
+ ) -> tuple[PreparedImage, ...]:
234
+ """Download and validate images sequentially before native submission."""
235
+
236
+ if not references:
237
+ return ()
238
+ if min(
239
+ max_count,
240
+ max_image_bytes,
241
+ max_total_bytes,
242
+ ) <= 0 or min(fetch_timeout_seconds, total_timeout_seconds) <= 0:
243
+ raise ValueError("image preparation limits must be positive")
244
+ if len(references) > max_count:
245
+ raise ImageInputUnavailable(
246
+ f"本条消息共包含 {len(references)} 张图片,超过当前 {max_count} 张上限;"
247
+ "请拆分后重试。本条消息未执行。"
248
+ )
249
+
250
+ async def prepare_all() -> tuple[PreparedImage, ...]:
251
+ prepared: list[PreparedImage] = []
252
+ total_bytes = 0
253
+ for reference in references:
254
+ try:
255
+ async with asyncio.timeout(fetch_timeout_seconds):
256
+ body = await channel.download_resource(
257
+ reference.file_key,
258
+ resource_type="image",
259
+ message_id=reference.message_id,
260
+ )
261
+ except TimeoutError as error:
262
+ raise ImageInputUnavailable(
263
+ "读取图片超时,本条消息未执行;请重新发送。"
264
+ ) from error
265
+ except Exception as error:
266
+ raise ImageInputUnavailable(
267
+ "无法读取消息中的图片;它可能已被删除、设为保密,"
268
+ "或应用缺少消息读取权限。本条消息未执行。"
269
+ ) from error
270
+ if not isinstance(body, bytes) or not body:
271
+ raise ImageInputUnavailable(
272
+ "无法读取消息中的图片;它可能已被删除、设为保密,"
273
+ "或应用缺少消息读取权限。本条消息未执行。"
274
+ )
275
+ size = len(body)
276
+ if size > max_image_bytes:
277
+ raise ImageInputUnavailable(
278
+ f"图片大小超过当前 {max_image_bytes // (1024 * 1024)} MB 上限,"
279
+ "请压缩后重试。本条消息未执行。"
280
+ )
281
+ total_bytes += size
282
+ if total_bytes > max_total_bytes:
283
+ raise ImageInputUnavailable(
284
+ f"图片总大小超过当前 {max_total_bytes // (1024 * 1024)} MB 上限,"
285
+ "请拆分后重试。本条消息未执行。"
286
+ )
287
+ mime_type = _sniff_image_mime_type(body)
288
+ if mime_type is None:
289
+ raise ImageInputUnavailable(
290
+ "图片格式不受支持;目前支持 PNG、JPEG、GIF 和 WebP。"
291
+ "本条消息未执行。"
292
+ )
293
+ data = base64.b64encode(body).decode("ascii")
294
+ prepared.append(
295
+ PreparedImage(
296
+ reference=reference,
297
+ mime_type=mime_type,
298
+ size_bytes=size,
299
+ data_url=f"data:{mime_type};base64,{data}",
300
+ )
301
+ )
302
+ del body, data
303
+ return tuple(prepared)
304
+
305
+ try:
306
+ async with asyncio.timeout(total_timeout_seconds):
307
+ return await prepare_all()
308
+ except TimeoutError as error:
309
+ raise ImageInputUnavailable(
310
+ "读取本条消息的全部图片超时,本条消息未执行;请拆分后重试。"
311
+ ) from error
312
+
313
+
314
+ def image_prompt_references(
315
+ images: Sequence[PreparedImage],
316
+ ) -> ImagePromptReferences:
317
+ """Assign one prompt-local ``imgN`` ref in exact native input order."""
318
+
319
+ ordered_images = _ordered_images(images)
320
+ references: dict[ImagePromptReferenceKey, str] = {}
321
+ for index, image in enumerate(ordered_images, start=1):
322
+ key = _image_prompt_reference_key(image.reference)
323
+ if key in references:
324
+ raise ImageInputContractError(
325
+ "图片输入包含重复的 exact 资源,本条消息未执行。"
326
+ )
327
+ references[key] = f"img{index}"
328
+ return MappingProxyType(references)
329
+
330
+
331
+ def localize_image_markers(
332
+ text: str,
333
+ *,
334
+ source: ImageSource,
335
+ message_id: str,
336
+ image_prompt_refs: ImagePromptReferences,
337
+ ) -> str:
338
+ """Replace SDK-rendered Feishu image keys with prompt-local refs.
339
+
340
+ Only Markdown image targets backed by an exact prepared image are changed;
341
+ ordinary prose and fenced code remain byte-for-byte intact.
342
+ """
343
+
344
+ replacements: dict[str, str] = {}
345
+ for (candidate_source, candidate_message_id, file_key), prompt_ref in (
346
+ image_prompt_refs.items()
347
+ ):
348
+ if candidate_source != source or candidate_message_id != message_id:
349
+ continue
350
+ existing = replacements.get(file_key)
351
+ if existing is not None and existing != prompt_ref:
352
+ raise ImageInputContractError(
353
+ "同一消息图片对应多个本地引用,本条消息未执行。"
354
+ )
355
+ replacements[file_key] = prompt_ref
356
+ if not replacements:
357
+ return text
358
+
359
+ parts = text.split("```")
360
+ for index in range(0, len(parts), 2):
361
+ parts[index] = _IMAGE_MARKER_RE.sub(
362
+ lambda match: _replace_image_marker(match, replacements),
363
+ parts[index],
364
+ )
365
+ return "```".join(parts)
366
+
367
+
368
+ def compose_multimodal_input(
369
+ prompt_text: str,
370
+ *,
371
+ images: tuple[PreparedImage, ...],
372
+ image_prompt_refs: ImagePromptReferences | None = None,
373
+ ) -> str | list[Any]:
374
+ """Place labeled native images before one complete final text prompt."""
375
+
376
+ if not images:
377
+ if image_prompt_refs:
378
+ raise ImageInputContractError(
379
+ "无图片输入不能携带图片本地引用,本条消息未执行。"
380
+ )
381
+ return prompt_text
382
+ ordered_images = _ordered_images(images)
383
+ expected_refs = image_prompt_references(ordered_images)
384
+ resolved_refs = expected_refs if image_prompt_refs is None else image_prompt_refs
385
+ if dict(resolved_refs) != dict(expected_refs):
386
+ raise ImageInputContractError(
387
+ "图片输入与本地引用不一致,本条消息未执行。"
388
+ )
389
+ counts = {
390
+ source: sum(image.reference.source == source for image in images)
391
+ for source in _IMAGE_SOURCE_ORDER
392
+ }
393
+ indexes = {source: 0 for source in _IMAGE_SOURCE_ORDER}
394
+ items: list[Any] = []
395
+ for image in ordered_images:
396
+ reference = image.reference
397
+ indexes[reference.source] += 1
398
+ label = {
399
+ "kind": "feishu_image_input",
400
+ "ref": resolved_refs[_image_prompt_reference_key(reference)],
401
+ "source": reference.source,
402
+ "index": indexes[reference.source],
403
+ "count": counts[reference.source],
404
+ "mime_type": image.mime_type,
405
+ "size_bytes": image.size_bytes,
406
+ }
407
+ items.append(TextInput(json.dumps(label, ensure_ascii=False)))
408
+ items.append(ImageInput(image.data_url))
409
+ items.append(TextInput(prompt_text))
410
+ return items
411
+
412
+
413
+ def _ordered_images(
414
+ images: Sequence[PreparedImage],
415
+ ) -> tuple[PreparedImage, ...]:
416
+ ordered = tuple(
417
+ image
418
+ for source in _IMAGE_SOURCE_ORDER
419
+ for image in images
420
+ if image.reference.source == source
421
+ )
422
+ if len(ordered) != len(images):
423
+ raise ImageInputContractError(
424
+ "图片输入包含未知的历史来源,本条消息未执行。"
425
+ )
426
+ return ordered
427
+
428
+
429
+ def _image_prompt_reference_key(
430
+ reference: ImageReference,
431
+ ) -> ImagePromptReferenceKey:
432
+ return (reference.source, reference.message_id, reference.file_key)
433
+
434
+
435
+ def _replace_image_marker(
436
+ match: re.Match[str],
437
+ replacements: Mapping[str, str],
438
+ ) -> str:
439
+ file_key = match.group(1).strip()
440
+ prompt_ref = replacements.get(file_key)
441
+ if prompt_ref is None:
442
+ return match.group(0)
443
+ rendered = match.group(0)
444
+ relative_start = match.start(1) - match.start(0)
445
+ relative_end = match.end(1) - match.start(0)
446
+ return rendered[:relative_start] + prompt_ref + rendered[relative_end:]
447
+
448
+
449
+ def normalized_message_type(message: Any) -> str:
450
+ return normalized_content_type(
451
+ message,
452
+ default="text",
453
+ conflict_error=ImageInputContractError,
454
+ )
455
+
456
+
457
+ def _message_id(message: Any) -> str:
458
+ return str(getattr(message, "message_id", None) or getattr(message, "id", ""))
459
+
460
+
461
+ def _resource_type(resource: Any) -> str | None:
462
+ return _nonempty_string(getattr(resource, "type", None))
463
+
464
+
465
+ def _selected_post_elements(message: Any) -> tuple[dict[str, Any], ...] | None:
466
+ content = getattr(message, "content", None)
467
+ post = getattr(content, "post", None)
468
+ if not isinstance(post, dict) or not post:
469
+ return None
470
+ if "content" in post:
471
+ documents = (post,)
472
+ else:
473
+ documents = tuple(value for value in post.values() if isinstance(value, dict))
474
+ if not documents:
475
+ return None
476
+ document = documents[0]
477
+ content_v2 = document.get("content_v2")
478
+ paragraphs = (
479
+ content_v2
480
+ if isinstance(content_v2, list) and content_v2
481
+ else document.get("content")
482
+ )
483
+ if not isinstance(paragraphs, list):
484
+ return ()
485
+ return tuple(
486
+ element
487
+ for paragraph in paragraphs
488
+ if isinstance(paragraph, list)
489
+ for element in paragraph
490
+ if isinstance(element, dict)
491
+ )
492
+
493
+
494
+ def _post_has_top_level_attachments(message: Any) -> bool:
495
+ """Return whether the public post AST declares a top-level files zone.
496
+
497
+ SDK 1.4.0 renders ``post.files`` beside the locale documents. Files also
498
+ receive a resource descriptor, but folders intentionally do not, so the
499
+ locale AST and ``resources`` cannot enforce the current-input policy on
500
+ their own. Treat a malformed non-null zone as present and fail closed.
501
+ """
502
+
503
+ content = getattr(message, "content", None)
504
+ post = getattr(content, "post", None)
505
+ if not isinstance(post, dict) or "files" not in post:
506
+ return False
507
+ files = post.get("files")
508
+ if files is None:
509
+ return False
510
+ if isinstance(files, list):
511
+ return bool(files)
512
+ return True
513
+
514
+
515
+ def _post_image_keys(elements: tuple[dict[str, Any], ...]) -> tuple[str, ...]:
516
+ keys: list[str] = []
517
+ for element in elements:
518
+ tag = element.get("tag")
519
+ if tag == "img":
520
+ key = _nonempty_string(element.get("image_key"))
521
+ if key is not None:
522
+ keys.append(key)
523
+ elif tag == "md":
524
+ keys.extend(_markdown_image_keys_outside_fences(element.get("text")))
525
+ return tuple(keys)
526
+
527
+
528
+ def _post_visible_resource_types(
529
+ elements: tuple[dict[str, Any], ...],
530
+ ) -> tuple[str, ...]:
531
+ result: list[str] = []
532
+ for element in elements:
533
+ tag = element.get("tag")
534
+ if tag == "img":
535
+ result.append("image")
536
+ elif tag == "media":
537
+ result.append("video")
538
+ elif tag in {"audio", "file"}:
539
+ result.append(str(tag))
540
+ elif tag == "md" and _markdown_image_keys_outside_fences(element.get("text")):
541
+ result.append("image")
542
+ return tuple(result)
543
+
544
+
545
+ def _markdown_image_keys_outside_fences(value: Any) -> tuple[str, ...]:
546
+ if not isinstance(value, str):
547
+ return ()
548
+ parts = value.split("```")
549
+ keys: list[str] = []
550
+ for index, part in enumerate(parts):
551
+ inside_fence = index % 2 == 1
552
+ if inside_fence:
553
+ continue
554
+ keys.extend(
555
+ key.strip()
556
+ for key in _IMAGE_MARKER_RE.findall(part)
557
+ if key.strip().startswith("img_")
558
+ )
559
+ return tuple(keys)
560
+
561
+
562
+ def _deduplicated(values: tuple[str, ...]) -> tuple[str, ...]:
563
+ return tuple(dict.fromkeys(values))
564
+
565
+
566
+ def _sniff_image_mime_type(body: bytes) -> str | None:
567
+ if body.startswith(b"\x89PNG\r\n\x1a\n"):
568
+ return "image/png"
569
+ if body.startswith(b"\xff\xd8\xff"):
570
+ return "image/jpeg"
571
+ if body.startswith((b"GIF87a", b"GIF89a")):
572
+ return "image/gif"
573
+ if len(body) >= 12 and body[:4] == b"RIFF" and body[8:12] == b"WEBP":
574
+ return "image/webp"
575
+ return None
576
+
577
+
578
+ def _nonempty_string(value: Any) -> str | None:
579
+ return value.strip() if isinstance(value, str) and value.strip() else None
@@ -0,0 +1,84 @@
1
+ """Canonical deployment location and names, without a second instance registry."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import os
7
+ import pwd
8
+ import stat
9
+ from collections.abc import Mapping
10
+ from pathlib import Path
11
+
12
+
13
+ INSTANCE_ROOT_MARKER = ".netizen-root"
14
+ INSTANCE_ROOT_MARKER_CONTENT = b"netizen-instance-root-v1\n"
15
+
16
+
17
+ def require_instance_root_marker(root: Path, *, uid: int | None = None) -> None:
18
+ """Read-only ownership proof shared by installation and managed startup."""
19
+ marker = root / INSTANCE_ROOT_MARKER
20
+ metadata = marker.lstat()
21
+ expected_uid = os.geteuid() if uid is None else uid
22
+ if (
23
+ not stat.S_ISREG(metadata.st_mode)
24
+ or metadata.st_uid != expected_uid
25
+ or stat.S_IMODE(metadata.st_mode) != 0o600
26
+ ):
27
+ raise ValueError(f"instance root marker is not recognized: {marker}")
28
+ descriptor = os.open(marker, os.O_RDONLY | os.O_NOFOLLOW | os.O_CLOEXEC | os.O_NONBLOCK)
29
+ with os.fdopen(descriptor, "rb") as source:
30
+ opened = os.fstat(source.fileno())
31
+ if (
32
+ not stat.S_ISREG(opened.st_mode)
33
+ or opened.st_uid != expected_uid
34
+ or stat.S_IMODE(opened.st_mode) != 0o600
35
+ or (opened.st_dev, opened.st_ino) != (metadata.st_dev, metadata.st_ino)
36
+ or source.read(len(INSTANCE_ROOT_MARKER_CONTENT) + 1) != INSTANCE_ROOT_MARKER_CONTENT
37
+ ):
38
+ raise ValueError(f"instance root marker is not recognized: {marker}")
39
+
40
+
41
+ def resolve_instance_root(
42
+ root: str | Path | None = None,
43
+ *,
44
+ environ: Mapping[str, str] | None = None,
45
+ account_home: Path | None = None,
46
+ cwd: Path | None = None,
47
+ ) -> Path:
48
+ """Resolve explicit argument > environment > effective account default once."""
49
+
50
+ env = os.environ if environ is None else environ
51
+ home = (
52
+ Path(pwd.getpwuid(os.geteuid()).pw_dir)
53
+ if account_home is None else account_home
54
+ )
55
+ if not home.is_absolute() or home == Path(home.anchor):
56
+ raise ValueError("account home must be an absolute non-root path")
57
+ selected = root if root is not None else env.get("NETIZEN_ROOT", home / ".netizen")
58
+ raw = os.fspath(selected)
59
+ if not raw.strip() or any(ord(character) < 32 for character in raw):
60
+ raise ValueError("NETIZEN_ROOT must be a non-empty path without control characters")
61
+ if raw == "~" or raw.startswith("~/"):
62
+ path = home / raw[2:] if raw != "~" else home
63
+ elif raw.startswith("~"):
64
+ raise ValueError("NETIZEN_ROOT only supports the effective account's ~/ prefix")
65
+ else:
66
+ path = Path(raw)
67
+ if not path.is_absolute():
68
+ path = (Path.cwd() if cwd is None else cwd) / path
69
+ canonical = path.resolve()
70
+ if canonical == Path(canonical.anchor) or canonical == home.resolve():
71
+ raise ValueError("NETIZEN_ROOT must not be a filesystem root or the account home")
72
+ return canonical
73
+
74
+
75
+ def instance_digest(root: Path) -> str:
76
+ return hashlib.sha256(os.fsencode(root.resolve())).hexdigest()[:24]
77
+
78
+
79
+ def systemd_service_name(root: Path) -> str:
80
+ return f"netizen-{instance_digest(root)}.service"
81
+
82
+
83
+ def launch_agent_label(root: Path) -> str:
84
+ return f"io.github.lijingda.netizen.{instance_digest(root)}"