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,644 @@
|
|
|
1
|
+
"""Prepare native input from exact current, quoted and supplemental messages.
|
|
2
|
+
|
|
3
|
+
The caller captures admission before preparation and owns submission, context
|
|
4
|
+
cursor commits and receipts. This component only reads messages and images;
|
|
5
|
+
prepared context anchors are data, not evidence of native acceptance.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import asyncio
|
|
11
|
+
import logging
|
|
12
|
+
from collections.abc import Callable, Sequence
|
|
13
|
+
from dataclasses import dataclass, replace
|
|
14
|
+
from typing import Any, Literal, Protocol
|
|
15
|
+
|
|
16
|
+
from ..domain import FeishuScope, MessageContextAnchor, ScopeKind
|
|
17
|
+
from ..image_inputs import (
|
|
18
|
+
ImageDownloadChannel,
|
|
19
|
+
ImageInputError,
|
|
20
|
+
ImageInputUnavailable,
|
|
21
|
+
ImagePromptReferences,
|
|
22
|
+
ImageReference,
|
|
23
|
+
compose_multimodal_input,
|
|
24
|
+
image_prompt_references,
|
|
25
|
+
image_references,
|
|
26
|
+
localize_image_markers,
|
|
27
|
+
prepare_images,
|
|
28
|
+
)
|
|
29
|
+
from ..message_history import (
|
|
30
|
+
MessageHistoryError,
|
|
31
|
+
MessageHistoryReader,
|
|
32
|
+
MessageHistoryRef,
|
|
33
|
+
MessageHistoryUnavailable,
|
|
34
|
+
)
|
|
35
|
+
from ..message_preparation import MessagePreparationError, prepare_message_content
|
|
36
|
+
from ..message_projection import (
|
|
37
|
+
HistoricalMessageError,
|
|
38
|
+
HistoricalMessageProjection,
|
|
39
|
+
SupplementalContextStats,
|
|
40
|
+
SupplementalMessageOmission,
|
|
41
|
+
compose_message_context_prompt,
|
|
42
|
+
historical_message_deleted,
|
|
43
|
+
normalized_historical_message_type,
|
|
44
|
+
project_quoted_message,
|
|
45
|
+
project_supplemental_message,
|
|
46
|
+
select_supplemental_messages,
|
|
47
|
+
)
|
|
48
|
+
from ..prompt_projection import (
|
|
49
|
+
CardAnswerProjection,
|
|
50
|
+
CurrentMessageProjection,
|
|
51
|
+
MessageInputProjection,
|
|
52
|
+
PromptProjectionError,
|
|
53
|
+
ScheduledInputProjection,
|
|
54
|
+
project_current_content,
|
|
55
|
+
render_plain_prompt,
|
|
56
|
+
)
|
|
57
|
+
from ..quoted_context import (
|
|
58
|
+
QuotedMessageUnavailable,
|
|
59
|
+
compose_quoted_prompt,
|
|
60
|
+
validate_quoted_message,
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
logger = logging.getLogger(__name__)
|
|
65
|
+
|
|
66
|
+
_QUOTE_FETCH_TIMEOUT_SECONDS = 10.0
|
|
67
|
+
_CONTEXT_PREPARATION_TIMEOUT_SECONDS = 60.0
|
|
68
|
+
_CONTEXT_FETCH_TIMEOUT_SECONDS = 10.0
|
|
69
|
+
_CONTEXT_FETCH_CONCURRENCY = 4
|
|
70
|
+
_CONTEXT_MESSAGE_LIMIT = 50
|
|
71
|
+
_CONTEXT_TEXT_LIMIT = 64_000
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class MessageInputChannel(ImageDownloadChannel, Protocol):
|
|
75
|
+
"""Only the public read operations required to prepare a prompt."""
|
|
76
|
+
|
|
77
|
+
async def fetch_inbound_message(self, message_id: str) -> Any: ...
|
|
78
|
+
|
|
79
|
+
async def fetch_quoted_context(self, message_id: str) -> Any: ...
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@dataclass(frozen=True, slots=True)
|
|
83
|
+
class PreparedInput:
|
|
84
|
+
"""Transient input and optional catch-up facts, without submission authority."""
|
|
85
|
+
|
|
86
|
+
native_input: str | list[Any]
|
|
87
|
+
context_anchor: MessageContextAnchor | None = None
|
|
88
|
+
context_stats: SupplementalContextStats | None = None
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
class MessageInputPreparer:
|
|
92
|
+
"""Own message preparation while callers retain operation identity and locks."""
|
|
93
|
+
|
|
94
|
+
def __init__(self, *, channel: MessageInputChannel) -> None:
|
|
95
|
+
self._channel = channel
|
|
96
|
+
|
|
97
|
+
async def prepare(
|
|
98
|
+
self,
|
|
99
|
+
*,
|
|
100
|
+
source_message: Any,
|
|
101
|
+
quoted_target_id: str | None,
|
|
102
|
+
current: MessageInputProjection,
|
|
103
|
+
current_images: tuple[ImageReference, ...] = (),
|
|
104
|
+
) -> PreparedInput:
|
|
105
|
+
"""Prepare current-only input, including its optional explicit quote."""
|
|
106
|
+
|
|
107
|
+
self._validate_anchor_material(current, quoted_target_id, current_images)
|
|
108
|
+
if isinstance(current, (ScheduledInputProjection, CardAnswerProjection)):
|
|
109
|
+
return PreparedInput(native_input=render_plain_prompt(current))
|
|
110
|
+
|
|
111
|
+
try:
|
|
112
|
+
current_fallback = await self._prepare_message_content(
|
|
113
|
+
source_message, source="current",
|
|
114
|
+
)
|
|
115
|
+
quoted = None
|
|
116
|
+
fallback_text = None
|
|
117
|
+
quoted_images: tuple[ImageReference, ...] = ()
|
|
118
|
+
if quoted_target_id is not None:
|
|
119
|
+
async with asyncio.timeout(_QUOTE_FETCH_TIMEOUT_SECONDS):
|
|
120
|
+
quoted = await self._channel.fetch_inbound_message(
|
|
121
|
+
quoted_target_id
|
|
122
|
+
)
|
|
123
|
+
if quoted is None:
|
|
124
|
+
raise QuotedMessageUnavailable(
|
|
125
|
+
"无法读取被引用的消息;它可能已撤回,"
|
|
126
|
+
"或应用缺少消息读取权限。本条消息未执行。"
|
|
127
|
+
)
|
|
128
|
+
validate_quoted_message(
|
|
129
|
+
quoted,
|
|
130
|
+
expected_message_id=quoted_target_id,
|
|
131
|
+
expected_chat_id=str(source_message.conversation.chat_id),
|
|
132
|
+
)
|
|
133
|
+
quoted_images = image_references(
|
|
134
|
+
quoted,
|
|
135
|
+
source="quoted_message",
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
fallback_text = await self._prepare_message_content(
|
|
139
|
+
quoted, source="quoted",
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
image_references_to_prepare = quoted_images + current_images
|
|
143
|
+
prepared_images = await prepare_images(
|
|
144
|
+
self._channel,
|
|
145
|
+
image_references_to_prepare,
|
|
146
|
+
)
|
|
147
|
+
quoted_read_keys = tuple(
|
|
148
|
+
image.reference.file_key
|
|
149
|
+
for image in prepared_images
|
|
150
|
+
if image.reference.source == "quoted_message"
|
|
151
|
+
)
|
|
152
|
+
prompt_image_refs = image_prompt_references(prepared_images)
|
|
153
|
+
rendered_current = self._render_current_content(
|
|
154
|
+
source_message,
|
|
155
|
+
current,
|
|
156
|
+
fallback_text=current_fallback,
|
|
157
|
+
images=prepared_images,
|
|
158
|
+
image_prompt_refs=prompt_image_refs,
|
|
159
|
+
)
|
|
160
|
+
prompt_text = render_plain_prompt(rendered_current)
|
|
161
|
+
if quoted is not None:
|
|
162
|
+
assert isinstance(rendered_current, CurrentMessageProjection)
|
|
163
|
+
prompt_text = compose_quoted_prompt(
|
|
164
|
+
quoted,
|
|
165
|
+
rendered_current,
|
|
166
|
+
interactive_fallback_text=fallback_text,
|
|
167
|
+
read_image_keys=quoted_read_keys,
|
|
168
|
+
image_prompt_refs=prompt_image_refs,
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
return PreparedInput(
|
|
172
|
+
native_input=compose_multimodal_input(
|
|
173
|
+
prompt_text,
|
|
174
|
+
images=prepared_images,
|
|
175
|
+
image_prompt_refs=prompt_image_refs,
|
|
176
|
+
),
|
|
177
|
+
)
|
|
178
|
+
except (HistoricalMessageError, ImageInputError, PromptProjectionError):
|
|
179
|
+
raise
|
|
180
|
+
except TimeoutError as error:
|
|
181
|
+
raise QuotedMessageUnavailable(
|
|
182
|
+
"读取被引用消息超时,本条消息未执行;"
|
|
183
|
+
"请重新发送。"
|
|
184
|
+
) from error
|
|
185
|
+
except Exception as error:
|
|
186
|
+
logger.warning(
|
|
187
|
+
"prompt context preparation failed",
|
|
188
|
+
extra={"error_type": type(error).__name__},
|
|
189
|
+
)
|
|
190
|
+
if quoted_target_id is not None:
|
|
191
|
+
raise QuotedMessageUnavailable(
|
|
192
|
+
"无法读取被引用的消息;它可能已撤回,"
|
|
193
|
+
"或应用缺少消息读取权限。本条消息未执行。"
|
|
194
|
+
) from error
|
|
195
|
+
raise ImageInputUnavailable(
|
|
196
|
+
"无法处理消息中的图片,本条消息未执行;请重新发送。"
|
|
197
|
+
) from error
|
|
198
|
+
|
|
199
|
+
async def prepare_catch_up(
|
|
200
|
+
self,
|
|
201
|
+
*,
|
|
202
|
+
source_message: Any,
|
|
203
|
+
scope: FeishuScope,
|
|
204
|
+
lower: MessageContextAnchor,
|
|
205
|
+
upper_id: str,
|
|
206
|
+
quoted_target_id: str | None,
|
|
207
|
+
current: MessageInputProjection,
|
|
208
|
+
current_images: tuple[ImageReference, ...] = (),
|
|
209
|
+
message_history: MessageHistoryReader | None,
|
|
210
|
+
) -> PreparedInput:
|
|
211
|
+
"""Prepare a bounded history window and return its exact upper anchor."""
|
|
212
|
+
|
|
213
|
+
self._validate_anchor_material(current, quoted_target_id, current_images)
|
|
214
|
+
if isinstance(current, (ScheduledInputProjection, CardAnswerProjection)) and upper_id != current.message_id:
|
|
215
|
+
raise PromptProjectionError(
|
|
216
|
+
"本次输入的上下文边界与反馈消息不一致,本条消息未执行。"
|
|
217
|
+
)
|
|
218
|
+
reader = message_history
|
|
219
|
+
if reader is None:
|
|
220
|
+
raise MessageHistoryUnavailable(
|
|
221
|
+
"群聊上下文读取能力尚不可用,本条消息未执行;请联系维护者。"
|
|
222
|
+
)
|
|
223
|
+
|
|
224
|
+
try:
|
|
225
|
+
async with asyncio.timeout(_CONTEXT_PREPARATION_TIMEOUT_SECONDS):
|
|
226
|
+
current_fallback = (
|
|
227
|
+
await self._prepare_message_content(source_message, source="current")
|
|
228
|
+
if isinstance(current, CurrentMessageProjection)
|
|
229
|
+
else None
|
|
230
|
+
)
|
|
231
|
+
window = await reader.read_window(scope, lower, upper_id)
|
|
232
|
+
fetched = await self._fetch_history_candidates(
|
|
233
|
+
scope,
|
|
234
|
+
window.candidates,
|
|
235
|
+
)
|
|
236
|
+
by_id = {
|
|
237
|
+
reference.message_id: value
|
|
238
|
+
for reference, value in zip(window.candidates, fetched)
|
|
239
|
+
}
|
|
240
|
+
attribution_names = {
|
|
241
|
+
reference.message_id: reference.sender_name
|
|
242
|
+
for reference in window.candidates
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
quoted_input: tuple[Any, str | None] | None = None
|
|
246
|
+
quoted_projection: HistoricalMessageProjection | None = None
|
|
247
|
+
if quoted_target_id is not None:
|
|
248
|
+
quoted_input = by_id.get(quoted_target_id)
|
|
249
|
+
if quoted_input is None:
|
|
250
|
+
quoted_input = await self._fetch_normalized_history_message(
|
|
251
|
+
quoted_target_id,
|
|
252
|
+
validate=lambda message: validate_quoted_message(
|
|
253
|
+
message,
|
|
254
|
+
expected_message_id=quoted_target_id,
|
|
255
|
+
expected_chat_id=scope.chat_id,
|
|
256
|
+
),
|
|
257
|
+
)
|
|
258
|
+
quoted_message, quoted_fallback = quoted_input
|
|
259
|
+
validate_quoted_message(
|
|
260
|
+
quoted_message,
|
|
261
|
+
expected_message_id=quoted_target_id,
|
|
262
|
+
expected_chat_id=scope.chat_id,
|
|
263
|
+
)
|
|
264
|
+
quoted_projection = project_quoted_message(
|
|
265
|
+
quoted_message,
|
|
266
|
+
interactive_fallback_text=quoted_fallback,
|
|
267
|
+
)
|
|
268
|
+
|
|
269
|
+
eligible_inputs: list[
|
|
270
|
+
tuple[Any, str | None, HistoricalMessageProjection]
|
|
271
|
+
] = []
|
|
272
|
+
projection_omissions: list[SupplementalMessageOmission] = []
|
|
273
|
+
for message, fallback in fetched:
|
|
274
|
+
projection = project_supplemental_message(
|
|
275
|
+
message,
|
|
276
|
+
interactive_fallback_text=fallback,
|
|
277
|
+
attribution_name=attribution_names.get(_message_id(message)),
|
|
278
|
+
)
|
|
279
|
+
if isinstance(projection, SupplementalMessageOmission):
|
|
280
|
+
projection_omissions.append(projection)
|
|
281
|
+
else:
|
|
282
|
+
eligible_inputs.append((message, fallback, projection))
|
|
283
|
+
|
|
284
|
+
supplemental_stats = SupplementalContextStats(
|
|
285
|
+
scanned_count=window.stats.raw_messages_scanned,
|
|
286
|
+
omitted_count=(
|
|
287
|
+
window.stats.omitted_messages + len(projection_omissions)
|
|
288
|
+
),
|
|
289
|
+
unsupported_omitted_count=sum(
|
|
290
|
+
omission.reason == "unsupported_message_type"
|
|
291
|
+
for omission in projection_omissions
|
|
292
|
+
),
|
|
293
|
+
truncated_before=window.stats.truncated_before,
|
|
294
|
+
message_limit_reached=window.stats.scan_limit_hit,
|
|
295
|
+
)
|
|
296
|
+
selection = select_supplemental_messages(
|
|
297
|
+
tuple(item[2] for item in eligible_inputs),
|
|
298
|
+
quoted_message_id=(
|
|
299
|
+
quoted_projection.message_id
|
|
300
|
+
if quoted_projection is not None
|
|
301
|
+
else None
|
|
302
|
+
),
|
|
303
|
+
supplemental_stats=supplemental_stats,
|
|
304
|
+
max_supplemental_messages=_CONTEXT_MESSAGE_LIMIT,
|
|
305
|
+
max_supplemental_text=_CONTEXT_TEXT_LIMIT,
|
|
306
|
+
)
|
|
307
|
+
image_eligible_ids = frozenset(
|
|
308
|
+
projection.message_id for projection in selection.messages
|
|
309
|
+
)
|
|
310
|
+
|
|
311
|
+
supplemental_image_references: list[ImageReference] = []
|
|
312
|
+
for message, _, projection in eligible_inputs:
|
|
313
|
+
if projection.message_id not in image_eligible_ids:
|
|
314
|
+
continue
|
|
315
|
+
supplemental_image_references.extend(
|
|
316
|
+
image_references(
|
|
317
|
+
message,
|
|
318
|
+
source="supplemental_message",
|
|
319
|
+
)
|
|
320
|
+
)
|
|
321
|
+
quoted_image_references: tuple[ImageReference, ...] = ()
|
|
322
|
+
if quoted_input is not None:
|
|
323
|
+
quoted_image_references = image_references(
|
|
324
|
+
quoted_input[0],
|
|
325
|
+
source="quoted_message",
|
|
326
|
+
)
|
|
327
|
+
except (MessageHistoryError, HistoricalMessageError, ImageInputError):
|
|
328
|
+
raise
|
|
329
|
+
except TimeoutError as error:
|
|
330
|
+
raise MessageHistoryUnavailable(
|
|
331
|
+
"读取并整理群聊上下文超过时间限制,本条消息未执行;请重试。"
|
|
332
|
+
) from error
|
|
333
|
+
except Exception as error:
|
|
334
|
+
logger.warning(
|
|
335
|
+
"catch-up context preparation failed",
|
|
336
|
+
extra={"error_type": type(error).__name__},
|
|
337
|
+
)
|
|
338
|
+
raise MessageHistoryUnavailable(
|
|
339
|
+
"无法安全整理群聊上下文,本条消息未执行;请重试。"
|
|
340
|
+
) from error
|
|
341
|
+
|
|
342
|
+
prepared_images = await prepare_images(
|
|
343
|
+
self._channel,
|
|
344
|
+
tuple(supplemental_image_references)
|
|
345
|
+
+ quoted_image_references
|
|
346
|
+
+ current_images,
|
|
347
|
+
)
|
|
348
|
+
|
|
349
|
+
selected_inputs = {
|
|
350
|
+
projection.message_id: (message, fallback)
|
|
351
|
+
for message, fallback, projection in eligible_inputs
|
|
352
|
+
if projection.message_id in image_eligible_ids
|
|
353
|
+
}
|
|
354
|
+
supplemental_projections: list[HistoricalMessageProjection] = []
|
|
355
|
+
for selected in selection.messages:
|
|
356
|
+
assert selected.message_id is not None
|
|
357
|
+
message, fallback = selected_inputs[selected.message_id]
|
|
358
|
+
projection = project_supplemental_message(
|
|
359
|
+
message,
|
|
360
|
+
interactive_fallback_text=fallback,
|
|
361
|
+
read_image_keys=self._prepared_image_keys(
|
|
362
|
+
prepared_images,
|
|
363
|
+
source="supplemental_message",
|
|
364
|
+
message_id=_message_id(message),
|
|
365
|
+
),
|
|
366
|
+
attribution_name=attribution_names.get(_message_id(message)),
|
|
367
|
+
)
|
|
368
|
+
if isinstance(projection, SupplementalMessageOmission):
|
|
369
|
+
raise MessageHistoryUnavailable(
|
|
370
|
+
"补充上下文消息在整理期间发生变化,本条消息未执行;请重试。"
|
|
371
|
+
)
|
|
372
|
+
supplemental_projections.append(projection)
|
|
373
|
+
selection = selection.reproject(supplemental_projections)
|
|
374
|
+
final_supplemental_ids = frozenset(
|
|
375
|
+
projection.message_id for projection in selection.messages
|
|
376
|
+
)
|
|
377
|
+
prepared_images = tuple(
|
|
378
|
+
image
|
|
379
|
+
for image in prepared_images
|
|
380
|
+
if image.reference.source != "supplemental_message"
|
|
381
|
+
or image.reference.message_id in final_supplemental_ids
|
|
382
|
+
)
|
|
383
|
+
|
|
384
|
+
final_quoted_projection = None
|
|
385
|
+
if quoted_input is not None:
|
|
386
|
+
quoted_message, quoted_fallback = quoted_input
|
|
387
|
+
final_quoted_projection = project_quoted_message(
|
|
388
|
+
quoted_message,
|
|
389
|
+
interactive_fallback_text=quoted_fallback,
|
|
390
|
+
read_image_keys=self._prepared_image_keys(
|
|
391
|
+
prepared_images,
|
|
392
|
+
source="quoted_message",
|
|
393
|
+
message_id=_message_id(quoted_message),
|
|
394
|
+
),
|
|
395
|
+
)
|
|
396
|
+
|
|
397
|
+
prompt_image_refs = image_prompt_references(prepared_images)
|
|
398
|
+
rendered_current = self._render_current_content(
|
|
399
|
+
source_message,
|
|
400
|
+
current,
|
|
401
|
+
fallback_text=current_fallback,
|
|
402
|
+
images=prepared_images,
|
|
403
|
+
image_prompt_refs=prompt_image_refs,
|
|
404
|
+
)
|
|
405
|
+
context = compose_message_context_prompt(
|
|
406
|
+
supplemental_selection=selection,
|
|
407
|
+
quoted_message=final_quoted_projection,
|
|
408
|
+
current=rendered_current,
|
|
409
|
+
image_prompt_refs=prompt_image_refs,
|
|
410
|
+
)
|
|
411
|
+
return PreparedInput(
|
|
412
|
+
native_input=compose_multimodal_input(
|
|
413
|
+
context.text,
|
|
414
|
+
images=prepared_images,
|
|
415
|
+
image_prompt_refs=prompt_image_refs,
|
|
416
|
+
),
|
|
417
|
+
context_anchor=window.upper,
|
|
418
|
+
context_stats=context.stats,
|
|
419
|
+
)
|
|
420
|
+
|
|
421
|
+
async def _fetch_history_candidates(
|
|
422
|
+
self,
|
|
423
|
+
scope: FeishuScope,
|
|
424
|
+
references: tuple[MessageHistoryRef, ...],
|
|
425
|
+
) -> tuple[tuple[Any, str | None], ...]:
|
|
426
|
+
semaphore = asyncio.Semaphore(_CONTEXT_FETCH_CONCURRENCY)
|
|
427
|
+
|
|
428
|
+
async def fetch(
|
|
429
|
+
reference: MessageHistoryRef,
|
|
430
|
+
) -> tuple[Any, str | None]:
|
|
431
|
+
async with semaphore:
|
|
432
|
+
value = await self._fetch_normalized_history_message(
|
|
433
|
+
reference.message_id,
|
|
434
|
+
validate=lambda message: self._validate_history_candidate(
|
|
435
|
+
scope, reference, message,
|
|
436
|
+
),
|
|
437
|
+
)
|
|
438
|
+
return value
|
|
439
|
+
|
|
440
|
+
tasks = tuple(asyncio.create_task(fetch(reference)) for reference in references)
|
|
441
|
+
if not tasks:
|
|
442
|
+
return ()
|
|
443
|
+
try:
|
|
444
|
+
return tuple(await asyncio.gather(*tasks))
|
|
445
|
+
finally:
|
|
446
|
+
for task in tasks:
|
|
447
|
+
if not task.done():
|
|
448
|
+
task.cancel()
|
|
449
|
+
await asyncio.gather(*tasks, return_exceptions=True)
|
|
450
|
+
|
|
451
|
+
async def _fetch_normalized_history_message(
|
|
452
|
+
self,
|
|
453
|
+
message_id: str,
|
|
454
|
+
*,
|
|
455
|
+
validate: Callable[[Any], None] | None = None,
|
|
456
|
+
) -> tuple[Any, str | None]:
|
|
457
|
+
try:
|
|
458
|
+
async with asyncio.timeout(_CONTEXT_FETCH_TIMEOUT_SECONDS):
|
|
459
|
+
message = await self._channel.fetch_inbound_message(message_id)
|
|
460
|
+
except TimeoutError as error:
|
|
461
|
+
raise MessageHistoryUnavailable(
|
|
462
|
+
"读取补充上下文消息超时,本条消息未执行;请重试。"
|
|
463
|
+
) from error
|
|
464
|
+
except Exception as error:
|
|
465
|
+
raise MessageHistoryUnavailable(
|
|
466
|
+
"无法读取补充上下文消息,本条消息未执行;请重试。"
|
|
467
|
+
) from error
|
|
468
|
+
if message is None:
|
|
469
|
+
raise MessageHistoryUnavailable(
|
|
470
|
+
"补充上下文消息已不可读取,本条消息未执行;请重试。"
|
|
471
|
+
)
|
|
472
|
+
|
|
473
|
+
if _message_id(message) != message_id:
|
|
474
|
+
raise MessageHistoryUnavailable(
|
|
475
|
+
"补充上下文 exact message ID 不一致,本条消息未执行。"
|
|
476
|
+
)
|
|
477
|
+
if validate is not None:
|
|
478
|
+
validate(message)
|
|
479
|
+
fallback_text = await self._prepare_message_content(message, source="historical")
|
|
480
|
+
return message, fallback_text
|
|
481
|
+
|
|
482
|
+
async def _prepare_message_content(
|
|
483
|
+
self,
|
|
484
|
+
message: Any,
|
|
485
|
+
*,
|
|
486
|
+
source: Literal["current", "quoted", "historical"],
|
|
487
|
+
) -> str | None:
|
|
488
|
+
"""One preparation path; only user-facing failure attribution varies."""
|
|
489
|
+
|
|
490
|
+
try:
|
|
491
|
+
return await prepare_message_content(
|
|
492
|
+
self._channel,
|
|
493
|
+
message,
|
|
494
|
+
timeout_seconds=(
|
|
495
|
+
_CONTEXT_FETCH_TIMEOUT_SECONDS
|
|
496
|
+
if source == "historical" else _QUOTE_FETCH_TIMEOUT_SECONDS
|
|
497
|
+
),
|
|
498
|
+
)
|
|
499
|
+
except MessagePreparationError as error:
|
|
500
|
+
if source == "historical":
|
|
501
|
+
labels = {
|
|
502
|
+
"timeout": "读取历史应用消息可见内容超时",
|
|
503
|
+
"unavailable": "无法读取历史应用消息可见内容",
|
|
504
|
+
"identity": "历史应用消息没有可验证的可见内容",
|
|
505
|
+
}
|
|
506
|
+
raise MessageHistoryUnavailable(
|
|
507
|
+
labels[error.reason] + ",本条消息未执行;请重试。"
|
|
508
|
+
) from error
|
|
509
|
+
if source == "quoted":
|
|
510
|
+
if error.reason == "identity":
|
|
511
|
+
raise QuotedMessageUnavailable(
|
|
512
|
+
"被引用的应用消息没有可验证的可见内容,"
|
|
513
|
+
"请复制内容后重试。本条消息未执行。"
|
|
514
|
+
) from error
|
|
515
|
+
if error.reason == "timeout":
|
|
516
|
+
raise QuotedMessageUnavailable(
|
|
517
|
+
"读取被引用消息超时,本条消息未执行;请重新发送。"
|
|
518
|
+
) from error
|
|
519
|
+
raise QuotedMessageUnavailable(
|
|
520
|
+
"无法读取被引用的消息;它可能已撤回,"
|
|
521
|
+
"或应用缺少消息读取权限。本条消息未执行。"
|
|
522
|
+
) from error
|
|
523
|
+
raise PromptProjectionError(
|
|
524
|
+
"无法读取当前卡片的可见内容,本条消息未执行;"
|
|
525
|
+
"请复制内容或重新发送。"
|
|
526
|
+
) from error
|
|
527
|
+
|
|
528
|
+
@staticmethod
|
|
529
|
+
def _validate_history_candidate(
|
|
530
|
+
scope: FeishuScope,
|
|
531
|
+
reference: MessageHistoryRef,
|
|
532
|
+
message: Any,
|
|
533
|
+
) -> None:
|
|
534
|
+
if _message_id(message) != reference.message_id:
|
|
535
|
+
raise MessageHistoryUnavailable(
|
|
536
|
+
"补充上下文 exact message ID 不一致,本条消息未执行。"
|
|
537
|
+
)
|
|
538
|
+
conversation = getattr(message, "conversation", None)
|
|
539
|
+
if str(getattr(conversation, "chat_id", "") or "") != scope.chat_id:
|
|
540
|
+
raise MessageHistoryUnavailable(
|
|
541
|
+
"补充上下文消息不属于当前会话,本条消息未执行。"
|
|
542
|
+
)
|
|
543
|
+
thread_id = str(getattr(conversation, "thread_id", "") or "") or None
|
|
544
|
+
if scope.kind is ScopeKind.GROUP and thread_id is not None:
|
|
545
|
+
raise MessageHistoryUnavailable(
|
|
546
|
+
"补充上下文消息不属于当前群聊主线,本条消息未执行。"
|
|
547
|
+
)
|
|
548
|
+
if scope.kind is ScopeKind.TOPIC and thread_id != scope.topic_id:
|
|
549
|
+
raise MessageHistoryUnavailable(
|
|
550
|
+
"补充上下文消息不属于当前话题,本条消息未执行。"
|
|
551
|
+
)
|
|
552
|
+
create_time = getattr(message, "create_time", None)
|
|
553
|
+
if isinstance(create_time, bool):
|
|
554
|
+
actual_create_time = None
|
|
555
|
+
elif isinstance(create_time, int):
|
|
556
|
+
actual_create_time = create_time
|
|
557
|
+
elif isinstance(create_time, str) and create_time.isdigit():
|
|
558
|
+
actual_create_time = int(create_time)
|
|
559
|
+
else:
|
|
560
|
+
actual_create_time = None
|
|
561
|
+
if actual_create_time != reference.create_time_ms:
|
|
562
|
+
raise MessageHistoryUnavailable(
|
|
563
|
+
"补充上下文消息时间与历史索引不一致,本条消息未执行;请重试。"
|
|
564
|
+
)
|
|
565
|
+
if historical_message_deleted(message):
|
|
566
|
+
return
|
|
567
|
+
sender = getattr(message, "sender", None)
|
|
568
|
+
sender_id = str(getattr(sender, "open_id", "") or "")
|
|
569
|
+
if (
|
|
570
|
+
sender_id != reference.sender_id
|
|
571
|
+
or bool(getattr(sender, "is_bot", False))
|
|
572
|
+
or str(getattr(sender, "sender_type", "user") or "") != "user"
|
|
573
|
+
):
|
|
574
|
+
raise MessageHistoryUnavailable(
|
|
575
|
+
"补充上下文消息发送者与历史索引不一致,本条消息未执行;请重试。"
|
|
576
|
+
)
|
|
577
|
+
actual_type = normalized_historical_message_type(message)
|
|
578
|
+
expected_type = "media" if reference.message_type == "video" else reference.message_type
|
|
579
|
+
if actual_type != expected_type:
|
|
580
|
+
raise MessageHistoryUnavailable(
|
|
581
|
+
"补充上下文消息类型与历史索引不一致,本条消息未执行;请重试。"
|
|
582
|
+
)
|
|
583
|
+
|
|
584
|
+
def _render_current_content(
|
|
585
|
+
self,
|
|
586
|
+
message: Any,
|
|
587
|
+
current: MessageInputProjection,
|
|
588
|
+
*,
|
|
589
|
+
fallback_text: str | None,
|
|
590
|
+
images: Sequence[Any],
|
|
591
|
+
image_prompt_refs: ImagePromptReferences,
|
|
592
|
+
) -> MessageInputProjection:
|
|
593
|
+
if isinstance(current, (ScheduledInputProjection, CardAnswerProjection)):
|
|
594
|
+
return current
|
|
595
|
+
current = project_current_content(
|
|
596
|
+
message,
|
|
597
|
+
current,
|
|
598
|
+
interactive_fallback_text=fallback_text,
|
|
599
|
+
read_image_keys=self._prepared_image_keys(
|
|
600
|
+
images,
|
|
601
|
+
source="current_message",
|
|
602
|
+
message_id=current.message_id,
|
|
603
|
+
),
|
|
604
|
+
)
|
|
605
|
+
return replace(
|
|
606
|
+
current,
|
|
607
|
+
request_text=localize_image_markers(
|
|
608
|
+
current.request_text,
|
|
609
|
+
source="current_message",
|
|
610
|
+
message_id=current.message_id,
|
|
611
|
+
image_prompt_refs=image_prompt_refs,
|
|
612
|
+
),
|
|
613
|
+
)
|
|
614
|
+
|
|
615
|
+
@staticmethod
|
|
616
|
+
def _validate_anchor_material(
|
|
617
|
+
current: MessageInputProjection,
|
|
618
|
+
quoted_target_id: str | None,
|
|
619
|
+
current_images: tuple[ImageReference, ...],
|
|
620
|
+
) -> None:
|
|
621
|
+
if isinstance(current, (ScheduledInputProjection, CardAnswerProjection)) and (
|
|
622
|
+
quoted_target_id is not None or current_images
|
|
623
|
+
):
|
|
624
|
+
raise PromptProjectionError(
|
|
625
|
+
"本次输入不能把反馈消息的引用或图片当作任务材料,本条消息未执行。"
|
|
626
|
+
)
|
|
627
|
+
|
|
628
|
+
@staticmethod
|
|
629
|
+
def _prepared_image_keys(
|
|
630
|
+
images: Sequence[Any],
|
|
631
|
+
*,
|
|
632
|
+
source: str,
|
|
633
|
+
message_id: str,
|
|
634
|
+
) -> tuple[str, ...]:
|
|
635
|
+
return tuple(
|
|
636
|
+
image.reference.file_key
|
|
637
|
+
for image in images
|
|
638
|
+
if image.reference.source == source
|
|
639
|
+
and image.reference.message_id == message_id
|
|
640
|
+
)
|
|
641
|
+
|
|
642
|
+
|
|
643
|
+
def _message_id(message: Any) -> str:
|
|
644
|
+
return str(getattr(message, "message_id", None) or getattr(message, "id", ""))
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""Public chat classification, bounded fields, and Channel reply identity checks."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Mapping
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def public_chat_kind(chat_info: object) -> str | None:
|
|
9
|
+
"""Classify public ChatInfo, keeping chat visibility separate from mode."""
|
|
10
|
+
# The SDK may normalize chat_type, or expose Feishu's private/public
|
|
11
|
+
# visibility there. Both group and topic modes are group conversations.
|
|
12
|
+
chat_type = getattr(chat_info, "chat_type", None)
|
|
13
|
+
if chat_type in {"group", "p2p"}:
|
|
14
|
+
return chat_type
|
|
15
|
+
chat_mode = getattr(chat_info, "chat_mode", None)
|
|
16
|
+
if chat_mode in {"group", "topic"}:
|
|
17
|
+
return "group"
|
|
18
|
+
return "p2p" if chat_mode == "p2p" else None
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _object_field(value: object, name: str) -> object | None:
|
|
22
|
+
if isinstance(value, Mapping):
|
|
23
|
+
return value.get(name)
|
|
24
|
+
return getattr(value, name, None)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _nonempty_field(value: object, name: str) -> str | None:
|
|
28
|
+
field = _object_field(value, name)
|
|
29
|
+
return field if isinstance(field, str) and field else None
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _send_result_error_code(result: object) -> int | None:
|
|
33
|
+
raw = _object_field(result, "raw")
|
|
34
|
+
code = _object_field(raw, "code")
|
|
35
|
+
if isinstance(code, int) and not isinstance(code, bool):
|
|
36
|
+
return code
|
|
37
|
+
error = _object_field(result, "error")
|
|
38
|
+
raw_code = _object_field(error, "raw_code")
|
|
39
|
+
if isinstance(raw_code, int) and not isinstance(raw_code, bool):
|
|
40
|
+
return raw_code
|
|
41
|
+
return None
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _progress_card_message_id(result: object) -> str | None:
|
|
45
|
+
"""Return an exact reply ID only when Feishu did not report failure."""
|
|
46
|
+
|
|
47
|
+
if getattr(result, "success", True) is False:
|
|
48
|
+
return None
|
|
49
|
+
if getattr(result, "chunk_ids", None):
|
|
50
|
+
return None
|
|
51
|
+
direct = _nonempty_field(result, "message_id")
|
|
52
|
+
raw = _object_field(result, "raw")
|
|
53
|
+
data = _object_field(raw, "data")
|
|
54
|
+
nested = _nonempty_field(data, "message_id")
|
|
55
|
+
if direct is not None and nested is not None and direct != nested:
|
|
56
|
+
return None
|
|
57
|
+
return direct or nested
|