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