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,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
|
netizen_cli/instance.py
ADDED
|
@@ -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)}"
|