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,643 @@
1
+ """Bounded, metadata-only Feishu history discovery for catch-up context.
2
+
3
+ The reader deliberately stops at exact message references. Message bodies are
4
+ normalized later through the public Channel SDK boundary, so this module never
5
+ reads or retains the ``Message.body`` returned by the OpenAPI list endpoint.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import asyncio
11
+ from dataclasses import dataclass
12
+ from typing import Any, Protocol
13
+
14
+ from lark_oapi.api.im.v1.model.get_message_request import GetMessageRequest
15
+ from lark_oapi.api.im.v1.model.list_message_request import ListMessageRequest
16
+
17
+ from .domain import FeishuScope, MessageContextAnchor, ScopeKind
18
+
19
+
20
+ _GET_TIMEOUT_SECONDS = 10.0
21
+ _PAGE_TIMEOUT_SECONDS = 10.0
22
+ _TOTAL_TIMEOUT_SECONDS = 60.0
23
+ _MAX_PAGES = 10
24
+ _MAX_RAW_MESSAGES = 500
25
+ _PAGE_SIZE = 50
26
+ _SORT_DESCENDING = "ByCreateTimeDesc"
27
+
28
+
29
+ class MessageHistoryError(RuntimeError):
30
+ """A history failure with a message that is safe to show to the user."""
31
+
32
+
33
+ class MessageHistoryUnavailable(MessageHistoryError):
34
+ """The exact history window could not be read within the bounded attempt."""
35
+
36
+
37
+ class MessageHistoryContractError(MessageHistoryError):
38
+ """The pinned SDK or platform response violated the expected public shape."""
39
+
40
+
41
+ @dataclass(frozen=True, slots=True)
42
+ class MessageHistoryRef:
43
+ """Stable metadata required to fetch and normalize one exact message.
44
+
45
+ ``sender_name`` is attribution-only display data carried from the same
46
+ list item: it never participates in identity consistency checks, which
47
+ stay on ``sender_id`` plus the user/bot type. Keeping it optional lets
48
+ nameless list items remain eligible candidates; the projection boundary
49
+ fails closed only when no sender name is verifiable at all.
50
+ """
51
+
52
+ message_id: str
53
+ create_time_ms: int
54
+ sender_id: str
55
+ message_type: str
56
+ sender_name: str | None = None
57
+
58
+
59
+ @dataclass(frozen=True, slots=True)
60
+ class MessageHistoryStats:
61
+ pages_scanned: int
62
+ raw_messages_scanned: int
63
+ duplicate_messages: int
64
+ ignored_after_upper: int
65
+ omitted_messages: int
66
+ truncated_before: bool
67
+ scan_limit_hit: bool
68
+
69
+
70
+ @dataclass(frozen=True, slots=True)
71
+ class MessageHistoryWindow:
72
+ """The open interval ``(lower, upper)`` in lower-to-upper order."""
73
+
74
+ lower: MessageContextAnchor
75
+ upper: MessageContextAnchor
76
+ candidates: tuple[MessageHistoryRef, ...]
77
+ stats: MessageHistoryStats
78
+
79
+
80
+ class MessageHistoryReader(Protocol):
81
+ async def resolve_topic_reply_anchor(
82
+ self, scope: FeishuScope,
83
+ ) -> MessageContextAnchor: ...
84
+
85
+ async def resolve_anchor(
86
+ self,
87
+ scope: FeishuScope,
88
+ message_id: str,
89
+ ) -> MessageContextAnchor: ...
90
+
91
+ async def read_window(
92
+ self,
93
+ scope: FeishuScope,
94
+ lower: MessageContextAnchor,
95
+ upper_id: str,
96
+ ) -> MessageHistoryWindow: ...
97
+
98
+
99
+ class FeishuMessageHistoryReader:
100
+ """Read one exact, bounded group-main or topic history window."""
101
+
102
+ def __init__(
103
+ self,
104
+ client: Any,
105
+ *,
106
+ get_timeout_seconds: float = _GET_TIMEOUT_SECONDS,
107
+ page_timeout_seconds: float = _PAGE_TIMEOUT_SECONDS,
108
+ total_timeout_seconds: float = _TOTAL_TIMEOUT_SECONDS,
109
+ max_pages: int = _MAX_PAGES,
110
+ max_raw_messages: int = _MAX_RAW_MESSAGES,
111
+ page_size: int = _PAGE_SIZE,
112
+ ) -> None:
113
+ if client is None:
114
+ raise ValueError("message history client is required")
115
+ if min(
116
+ get_timeout_seconds,
117
+ page_timeout_seconds,
118
+ total_timeout_seconds,
119
+ ) <= 0:
120
+ raise ValueError("message history timeouts must be positive")
121
+ if (
122
+ get_timeout_seconds > _GET_TIMEOUT_SECONDS
123
+ or page_timeout_seconds > _PAGE_TIMEOUT_SECONDS
124
+ or total_timeout_seconds > _TOTAL_TIMEOUT_SECONDS
125
+ ):
126
+ raise ValueError("message history timeouts exceed the safe maximum")
127
+ if min(max_pages, max_raw_messages, page_size) <= 0:
128
+ raise ValueError("message history limits must be positive")
129
+ if (
130
+ max_pages > _MAX_PAGES
131
+ or max_raw_messages > _MAX_RAW_MESSAGES
132
+ or page_size > _PAGE_SIZE
133
+ ):
134
+ raise ValueError("message history limits exceed the safe maximum")
135
+
136
+ self._client = client
137
+ self._get_timeout_seconds = get_timeout_seconds
138
+ self._page_timeout_seconds = page_timeout_seconds
139
+ self._total_timeout_seconds = total_timeout_seconds
140
+ self._max_pages = max_pages
141
+ self._max_raw_messages = max_raw_messages
142
+ self._page_size = page_size
143
+
144
+ async def resolve_topic_reply_anchor(
145
+ self, scope: FeishuScope,
146
+ ) -> MessageContextAnchor:
147
+ """Find one verified reply target inside an already identified topic.
148
+
149
+ This reads a single metadata page, not a guessed source history or a
150
+ new-topic recovery search. Any verified message in the exact topic is
151
+ a valid reply anchor; the later send must confirm that same topic.
152
+ """
153
+ if scope.kind is not ScopeKind.TOPIC:
154
+ raise ValueError("a topic Scope is required")
155
+ request = (ListMessageRequest.builder()
156
+ .container_id_type("thread").container_id(scope.topic_id)
157
+ .sort_type(_SORT_DESCENDING).page_size(self._page_size)
158
+ .with_sender_name(True).build())
159
+ try:
160
+ async with asyncio.timeout(self._total_timeout_seconds):
161
+ response = await self._list_page(request)
162
+ items = getattr(getattr(response, "data", None), "items", None)
163
+ if not isinstance(items, (list, tuple)):
164
+ raise MessageHistoryContractError("飞书话题消息列表缺少有效消息。")
165
+ for item in items[:self._page_size]:
166
+ if getattr(item, "deleted", None) is True:
167
+ continue
168
+ message_id = _required_item_string(item, "message_id")
169
+ listed = _anchor_from_exact_item(scope, item, expected_id=message_id)
170
+ exact = await self._get_exact_item(message_id)
171
+ if getattr(exact, "deleted", None) is True:
172
+ raise MessageHistoryUnavailable("话题回复锚点已删除,本次未执行。")
173
+ anchor = _anchor_from_exact_item(scope, exact, expected_id=message_id)
174
+ _validate_list_endpoint(exact, listed)
175
+ return anchor
176
+ except TimeoutError as error:
177
+ raise MessageHistoryUnavailable("读取话题回复锚点超时,本次未执行。") from error
178
+ raise MessageHistoryUnavailable("找不到可验证的话题回复锚点,本次未执行。")
179
+
180
+ async def resolve_anchor(
181
+ self,
182
+ scope: FeishuScope,
183
+ message_id: str,
184
+ ) -> MessageContextAnchor:
185
+ """Resolve and scope-check one exact message through typed ``aget``."""
186
+
187
+ _require_history_scope(scope)
188
+ exact_id = _required_string(message_id, "message_id")
189
+ item = await self._get_exact_item(exact_id)
190
+ return _anchor_from_exact_item(scope, item, expected_id=exact_id)
191
+
192
+ async def read_window(
193
+ self,
194
+ scope: FeishuScope,
195
+ lower: MessageContextAnchor,
196
+ upper_id: str,
197
+ ) -> MessageHistoryWindow:
198
+ """Return metadata refs strictly between two exact message endpoints."""
199
+
200
+ _require_history_scope(scope)
201
+ if not isinstance(lower, MessageContextAnchor):
202
+ raise ValueError("lower must be a MessageContextAnchor")
203
+ exact_upper_id = _required_string(upper_id, "upper_id")
204
+ if lower.message_id == exact_upper_id:
205
+ raise MessageHistoryContractError(
206
+ "上下文起止消息相同,本条消息未执行。"
207
+ )
208
+
209
+ try:
210
+ async with asyncio.timeout(self._total_timeout_seconds):
211
+ return await self._read_window(scope, lower, exact_upper_id)
212
+ except MessageHistoryError:
213
+ raise
214
+ except TimeoutError as exc:
215
+ raise MessageHistoryUnavailable(
216
+ "读取群聊上下文超过时间限制,本条消息未执行;请重试。"
217
+ ) from exc
218
+
219
+ async def _read_window(
220
+ self,
221
+ scope: FeishuScope,
222
+ lower: MessageContextAnchor,
223
+ upper_id: str,
224
+ ) -> MessageHistoryWindow:
225
+ lower_item = await self._get_exact_item(lower.message_id)
226
+ resolved_lower = _anchor_from_exact_item(
227
+ scope,
228
+ lower_item,
229
+ expected_id=lower.message_id,
230
+ )
231
+ if resolved_lower != lower:
232
+ raise MessageHistoryUnavailable(
233
+ "已保存的群聊上下文边界与飞书当前记录不一致,"
234
+ "本条消息未执行;请重试或重新配置会话。"
235
+ )
236
+
237
+ upper_item = await self._get_exact_item(upper_id)
238
+ upper = _anchor_from_exact_item(
239
+ scope,
240
+ upper_item,
241
+ expected_id=upper_id,
242
+ )
243
+ if upper.create_time_ms < lower.create_time_ms:
244
+ raise MessageHistoryContractError(
245
+ "群聊上下文消息顺序无法确认,本条消息未执行。"
246
+ )
247
+
248
+ return await self._scan_window(scope, lower, upper)
249
+
250
+ async def _get_exact_item(self, message_id: str) -> Any:
251
+ request = (
252
+ GetMessageRequest.builder()
253
+ .message_id(message_id)
254
+ .user_id_type("open_id")
255
+ .with_sender_name(True)
256
+ .build()
257
+ )
258
+ try:
259
+ async with asyncio.timeout(self._get_timeout_seconds):
260
+ response = await self._client.im.v1.message.aget(request)
261
+ except TimeoutError as exc:
262
+ raise MessageHistoryUnavailable(
263
+ "读取群聊上下文消息超时,本条消息未执行;请重试。"
264
+ ) from exc
265
+ except Exception as exc:
266
+ raise MessageHistoryUnavailable(
267
+ "无法读取群聊上下文消息,本条消息未执行;请重试。"
268
+ ) from exc
269
+
270
+ _require_success(response, operation="读取群聊上下文消息")
271
+ data = getattr(response, "data", None)
272
+ items = getattr(data, "items", None)
273
+ if not isinstance(items, (list, tuple)) or len(items) != 1:
274
+ raise MessageHistoryContractError(
275
+ "飞书没有返回唯一的群聊上下文消息,本条消息未执行。"
276
+ )
277
+ return items[0]
278
+
279
+ async def _scan_window(
280
+ self,
281
+ scope: FeishuScope,
282
+ lower: MessageContextAnchor,
283
+ upper: MessageContextAnchor,
284
+ ) -> MessageHistoryWindow:
285
+ page_token: str | None = None
286
+ seen_page_tokens: set[str] = set()
287
+ seen_message_ids: set[str] = set()
288
+ candidates_descending: list[MessageHistoryRef] = []
289
+ pages_scanned = 0
290
+ raw_messages_scanned = 0
291
+ duplicate_messages = 0
292
+ ignored_after_upper = 0
293
+ omitted_messages = 0
294
+ upper_found = False
295
+ lower_found = False
296
+ scan_limit_hit = False
297
+ crossed_lower_time = False
298
+ upper_visibility_retried = False
299
+
300
+ while pages_scanned < self._max_pages:
301
+ request = self._list_request(scope, upper, page_token=page_token)
302
+ response = await self._list_page(request)
303
+ pages_scanned += 1
304
+
305
+ data = getattr(response, "data", None)
306
+ items = getattr(data, "items", None)
307
+ if items is None:
308
+ page_items: tuple[Any, ...] = ()
309
+ elif isinstance(items, (list, tuple)):
310
+ page_items = tuple(items)
311
+ else:
312
+ raise MessageHistoryContractError(
313
+ "飞书群聊历史分页结构无法确认,本条消息未执行。"
314
+ )
315
+ if len(page_items) > self._page_size:
316
+ raise MessageHistoryContractError(
317
+ "飞书群聊历史分页超过已验证上限,本条消息未执行。"
318
+ )
319
+
320
+ for item in page_items:
321
+ if raw_messages_scanned >= self._max_raw_messages:
322
+ scan_limit_hit = True
323
+ break
324
+ raw_messages_scanned += 1
325
+
326
+ message_id = _required_item_string(item, "message_id")
327
+ if message_id in seen_message_ids:
328
+ duplicate_messages += 1
329
+ continue
330
+ seen_message_ids.add(message_id)
331
+
332
+ in_scope = _validate_list_item_scope(scope, item)
333
+ if not upper_found:
334
+ if message_id != upper.message_id:
335
+ ignored_after_upper += 1
336
+ continue
337
+ if not in_scope:
338
+ raise MessageHistoryContractError(
339
+ "飞书返回的当前消息不属于目标群聊范围,"
340
+ "本条消息未执行。"
341
+ )
342
+ _validate_list_endpoint(item, upper)
343
+ upper_found = True
344
+ continue
345
+
346
+ if message_id == lower.message_id:
347
+ if not in_scope:
348
+ raise MessageHistoryContractError(
349
+ "飞书返回的历史边界不属于目标群聊范围,"
350
+ "本条消息未执行。"
351
+ )
352
+ _validate_list_endpoint(item, lower)
353
+ lower_found = True
354
+ break
355
+
356
+ if not in_scope:
357
+ omitted_messages += 1
358
+ continue
359
+
360
+ reference = _candidate_reference(item)
361
+ if reference is None:
362
+ omitted_messages += 1
363
+ continue
364
+ if reference.create_time_ms > upper.create_time_ms:
365
+ raise MessageHistoryContractError(
366
+ "飞书群聊历史没有保持已请求的倒序,"
367
+ "本条消息未执行。"
368
+ )
369
+ if reference.create_time_ms < lower.create_time_ms:
370
+ crossed_lower_time = True
371
+ break
372
+ candidates_descending.append(reference)
373
+
374
+ if lower_found or crossed_lower_time:
375
+ break
376
+ if scan_limit_hit:
377
+ break
378
+
379
+ has_more = getattr(data, "has_more", None)
380
+ if has_more is None:
381
+ has_more = False
382
+ if not isinstance(has_more, bool):
383
+ raise MessageHistoryContractError(
384
+ "飞书群聊历史分页标记无法确认,本条消息未执行。"
385
+ )
386
+ if not has_more:
387
+ if (
388
+ not upper_found
389
+ and not upper_visibility_retried
390
+ and pages_scanned < self._max_pages
391
+ and raw_messages_scanned < self._max_raw_messages
392
+ ):
393
+ # Exact ``aget`` already proved that upper exists. Start
394
+ # one fresh list snapshot to cover bounded eventual
395
+ # visibility, while retaining the same total page/raw/time
396
+ # budgets. Advisory duplicate/after-upper counters also
397
+ # describe both bounded snapshots rather than one page
398
+ # chain. Message-ID deduplication restarts with the new
399
+ # snapshot: the first snapshot may already have exposed
400
+ # lower or real interval messages before upper became
401
+ # visible, and those IDs must remain usable after retry.
402
+ upper_visibility_retried = True
403
+ page_token = None
404
+ seen_page_tokens.clear()
405
+ seen_message_ids.clear()
406
+ continue
407
+ break
408
+ next_token = _optional_string(getattr(data, "page_token", None))
409
+ if next_token is None or next_token in seen_page_tokens:
410
+ raise MessageHistoryContractError(
411
+ "飞书群聊历史分页游标无法安全推进,本条消息未执行。"
412
+ )
413
+ seen_page_tokens.add(next_token)
414
+ page_token = next_token
415
+
416
+ if not upper_found:
417
+ raise MessageHistoryUnavailable(
418
+ "当前消息暂未出现在飞书群聊历史中,本条消息未执行;请重试。"
419
+ )
420
+
421
+ if (
422
+ not lower_found
423
+ and raw_messages_scanned >= self._max_raw_messages
424
+ ):
425
+ scan_limit_hit = True
426
+ if not lower_found and pages_scanned >= self._max_pages:
427
+ scan_limit_hit = True
428
+ truncated_before = not lower_found
429
+ stats = MessageHistoryStats(
430
+ pages_scanned=pages_scanned,
431
+ raw_messages_scanned=raw_messages_scanned,
432
+ duplicate_messages=duplicate_messages,
433
+ ignored_after_upper=ignored_after_upper,
434
+ omitted_messages=omitted_messages,
435
+ truncated_before=truncated_before,
436
+ scan_limit_hit=scan_limit_hit,
437
+ )
438
+ return MessageHistoryWindow(
439
+ lower=lower,
440
+ upper=upper,
441
+ candidates=tuple(reversed(candidates_descending)),
442
+ stats=stats,
443
+ )
444
+
445
+ def _list_request(
446
+ self,
447
+ scope: FeishuScope,
448
+ upper: MessageContextAnchor,
449
+ *,
450
+ page_token: str | None,
451
+ ) -> ListMessageRequest:
452
+ if scope.kind is ScopeKind.TOPIC:
453
+ assert scope.topic_id is not None
454
+ builder = (
455
+ ListMessageRequest.builder()
456
+ .container_id_type("thread")
457
+ .container_id(scope.topic_id)
458
+ .sort_type(_SORT_DESCENDING)
459
+ .page_size(self._page_size)
460
+ .with_sender_name(True)
461
+ )
462
+ else:
463
+ # ``end_time`` is seconds. Move to the following second even
464
+ # when upper is exactly on a second boundary, then seek the exact
465
+ # upper ID locally. This intentionally over-reads rather than
466
+ # risking exclusion of upper.
467
+ safe_end_second = upper.create_time_ms // 1_000 + 1
468
+ builder = (
469
+ ListMessageRequest.builder()
470
+ .container_id_type("chat")
471
+ .container_id(scope.chat_id)
472
+ .end_time(str(safe_end_second))
473
+ .sort_type(_SORT_DESCENDING)
474
+ .page_size(self._page_size)
475
+ .with_sender_name(True)
476
+ )
477
+ if page_token is not None:
478
+ builder = builder.page_token(page_token)
479
+ return builder.build()
480
+
481
+ async def _list_page(self, request: ListMessageRequest) -> Any:
482
+ try:
483
+ async with asyncio.timeout(self._page_timeout_seconds):
484
+ response = await self._client.im.v1.message.alist(request)
485
+ except TimeoutError as exc:
486
+ raise MessageHistoryUnavailable(
487
+ "读取群聊历史分页超时,本条消息未执行;请重试。"
488
+ ) from exc
489
+ except Exception as exc:
490
+ raise MessageHistoryUnavailable(
491
+ "无法读取群聊历史分页,本条消息未执行;请重试。"
492
+ ) from exc
493
+ _require_success(response, operation="读取群聊历史分页")
494
+ if getattr(response, "data", None) is None:
495
+ raise MessageHistoryContractError(
496
+ "飞书没有返回群聊历史分页数据,本条消息未执行。"
497
+ )
498
+ return response
499
+
500
+
501
+ def _require_history_scope(scope: FeishuScope) -> None:
502
+ if not isinstance(scope, FeishuScope):
503
+ raise ValueError("scope must be a FeishuScope")
504
+ if scope.kind not in (ScopeKind.GROUP, ScopeKind.TOPIC):
505
+ raise MessageHistoryContractError(
506
+ "私聊不支持补充群聊上下文,本条消息未执行。"
507
+ )
508
+
509
+
510
+ def _require_success(response: Any, *, operation: str) -> None:
511
+ success = getattr(response, "success", None)
512
+ try:
513
+ succeeded = success() if callable(success) else None
514
+ except Exception as exc:
515
+ raise MessageHistoryContractError(
516
+ f"{operation}的飞书响应无法确认,本条消息未执行。"
517
+ ) from exc
518
+ if succeeded is not True:
519
+ raise MessageHistoryUnavailable(
520
+ f"{operation}失败,本条消息未执行;请重试。"
521
+ )
522
+
523
+
524
+ def _anchor_from_exact_item(
525
+ scope: FeishuScope,
526
+ item: Any,
527
+ *,
528
+ expected_id: str,
529
+ ) -> MessageContextAnchor:
530
+ message_id = _required_item_string(item, "message_id")
531
+ if message_id != expected_id:
532
+ raise MessageHistoryContractError(
533
+ "飞书返回了错误的群聊上下文消息,本条消息未执行。"
534
+ )
535
+ chat_id = _required_item_string(item, "chat_id")
536
+ if chat_id != scope.chat_id:
537
+ raise MessageHistoryUnavailable(
538
+ "群聊上下文消息不属于当前会话,本条消息未执行。"
539
+ )
540
+ thread_id = _optional_string(getattr(item, "thread_id", None))
541
+ if scope.kind is ScopeKind.GROUP and thread_id is not None:
542
+ raise MessageHistoryUnavailable(
543
+ "群聊上下文消息不属于当前群聊主线,本条消息未执行。"
544
+ )
545
+ if scope.kind is ScopeKind.TOPIC and thread_id != scope.topic_id:
546
+ raise MessageHistoryUnavailable(
547
+ "群聊上下文消息不属于当前话题,本条消息未执行。"
548
+ )
549
+ return MessageContextAnchor(
550
+ message_id=message_id,
551
+ create_time_ms=_positive_timestamp(getattr(item, "create_time", None)),
552
+ )
553
+
554
+
555
+ def _validate_list_item_scope(scope: FeishuScope, item: Any) -> bool:
556
+ chat_id = _required_item_string(item, "chat_id")
557
+ if chat_id != scope.chat_id:
558
+ raise MessageHistoryContractError(
559
+ "飞书群聊历史包含其他会话的消息,本条消息未执行。"
560
+ )
561
+ thread_id = _optional_string(getattr(item, "thread_id", None))
562
+ if scope.kind is ScopeKind.TOPIC:
563
+ if thread_id != scope.topic_id:
564
+ raise MessageHistoryContractError(
565
+ "飞书话题历史包含其他话题的消息,本条消息未执行。"
566
+ )
567
+ return True
568
+ return thread_id is None
569
+
570
+
571
+ def _validate_list_endpoint(item: Any, anchor: MessageContextAnchor) -> None:
572
+ if _positive_timestamp(getattr(item, "create_time", None)) != (
573
+ anchor.create_time_ms
574
+ ):
575
+ raise MessageHistoryContractError(
576
+ "飞书群聊历史端点元数据发生冲突,本条消息未执行。"
577
+ )
578
+
579
+
580
+ def _candidate_reference(item: Any) -> MessageHistoryRef | None:
581
+ if getattr(item, "deleted", None) is True:
582
+ return None
583
+ message_type = _optional_string(getattr(item, "msg_type", None)) or "unknown"
584
+ if message_type == "system":
585
+ return None
586
+ sender = getattr(item, "sender", None)
587
+ if sender is None or _optional_string(getattr(sender, "sender_type", None)) != "user":
588
+ return None
589
+ if _optional_string(getattr(sender, "id_type", None)) != "open_id":
590
+ return None
591
+ sender_id = _optional_string(getattr(sender, "id", None))
592
+ if sender_id is None:
593
+ return None
594
+ return MessageHistoryRef(
595
+ message_id=_required_item_string(item, "message_id"),
596
+ create_time_ms=_positive_timestamp(getattr(item, "create_time", None)),
597
+ sender_id=sender_id,
598
+ message_type=message_type,
599
+ sender_name=_optional_string(getattr(sender, "sender_name", None)),
600
+ )
601
+
602
+
603
+ def _positive_timestamp(value: Any) -> int:
604
+ if isinstance(value, bool):
605
+ raise MessageHistoryContractError(
606
+ "飞书群聊历史缺少有效时间,本条消息未执行。"
607
+ )
608
+ if isinstance(value, int):
609
+ result = value
610
+ elif isinstance(value, str) and value.isdigit():
611
+ result = int(value)
612
+ else:
613
+ raise MessageHistoryContractError(
614
+ "飞书群聊历史缺少有效时间,本条消息未执行。"
615
+ )
616
+ if result <= 0:
617
+ raise MessageHistoryContractError(
618
+ "飞书群聊历史缺少有效时间,本条消息未执行。"
619
+ )
620
+ return result
621
+
622
+
623
+ def _required_item_string(item: Any, field: str) -> str:
624
+ value = _optional_string(getattr(item, field, None))
625
+ if value is None:
626
+ raise MessageHistoryContractError(
627
+ "飞书群聊历史缺少必要标识,本条消息未执行。"
628
+ )
629
+ return value
630
+
631
+
632
+ def _required_string(value: Any, field: str) -> str:
633
+ result = _optional_string(value)
634
+ if result is None:
635
+ raise ValueError(f"{field} must not be empty")
636
+ return result
637
+
638
+
639
+ def _optional_string(value: Any) -> str | None:
640
+ if not isinstance(value, str):
641
+ return None
642
+ stripped = value.strip()
643
+ return stripped or None
@@ -0,0 +1,60 @@
1
+ """Source-neutral asynchronous preparation of SDK-normalized message content.
2
+
3
+ Acquisition and exact source validation belong to the caller. In particular a
4
+ quote/history target must be checked before a card fallback can read it again.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import asyncio
10
+ from typing import Any
11
+
12
+ from .message_content import validate_interactive_version
13
+ from .quoted_context import (
14
+ interactive_quote_visible_text,
15
+ needs_interactive_fallback,
16
+ )
17
+
18
+
19
+ class MessagePreparationError(RuntimeError):
20
+ """A preparation failure; callers retain their source-specific messages."""
21
+
22
+ def __init__(self, reason: str) -> None:
23
+ super().__init__(reason)
24
+ self.reason = reason
25
+
26
+
27
+ async def prepare_message_content(
28
+ channel: Any,
29
+ message: Any,
30
+ *,
31
+ timeout_seconds: float,
32
+ ) -> str | None:
33
+ """Complete visible content once using the public SDK's existing fallback.
34
+
35
+ The SDK has already normalized the inbound/fetched message (including forward
36
+ expansion). Only its known interactive placeholder needs an extra public read.
37
+ The returned visible text is consumed by the same pure content projection for
38
+ current, quoted and supplemental messages. No source semantics are inferred.
39
+ """
40
+
41
+ validate_interactive_version(getattr(message, "content", None))
42
+ if not needs_interactive_fallback(message):
43
+ return None
44
+ message_id = getattr(message, "id", None) or getattr(message, "message_id", None)
45
+ if not isinstance(message_id, str) or not message_id:
46
+ raise MessagePreparationError("identity")
47
+ try:
48
+ async with asyncio.timeout(timeout_seconds):
49
+ fallback = await channel.fetch_quoted_context(message_id)
50
+ except TimeoutError as error:
51
+ raise MessagePreparationError("timeout") from error
52
+ except Exception as error:
53
+ raise MessagePreparationError("unavailable") from error
54
+ if (
55
+ fallback is None
56
+ or getattr(fallback, "message_id", None) != message_id
57
+ or getattr(fallback, "content_type", None) != "interactive"
58
+ ):
59
+ raise MessagePreparationError("identity")
60
+ return interactive_quote_visible_text(fallback)