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,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