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,83 @@
1
+ """Application management boundary shared by Feishu and Admin adapters."""
2
+
3
+ from .coordination import ScopeCoordinator
4
+ from .chat_labels import ChatLabel, ChatLabelProvider, ChatLabelResolver
5
+ from .service import (
6
+ ActivePointerChanged,
7
+ BindingPrimaryStatusResolution,
8
+ BindingStatusProjection,
9
+ BindingScopeMismatch,
10
+ ClosedSide,
11
+ CreatedBinding,
12
+ CurrentBindingChanged,
13
+ CurrentBindingTarget,
14
+ CurrentSideTarget,
15
+ ExactBindingTarget,
16
+ InstanceManagementService,
17
+ ManagementError,
18
+ ManagementRuntimePort,
19
+ NativeCatalogInconsistent,
20
+ NativeThreadMissing,
21
+ NativeThreadView,
22
+ NoCurrentBinding,
23
+ ProjectInventoryItem,
24
+ ProjectInventoryPage,
25
+ ProjectDeletionResult,
26
+ ReleasedBinding,
27
+ RenamedBinding,
28
+ RuntimePrecondition,
29
+ RuntimeSnapshots,
30
+ RuntimeStateChanged,
31
+ SessionInventoryItem,
32
+ SessionInventoryPage,
33
+ SessionInventoryState,
34
+ SessionQuery,
35
+ ScopeSessionPage,
36
+ SideIdentityMismatch,
37
+ SideTopicInventoryItem,
38
+ SideTopicInventoryPage,
39
+ StoppedBinding,
40
+ classify_native_thread_view,
41
+ )
42
+
43
+ __all__ = (
44
+ "ActivePointerChanged",
45
+ "BindingPrimaryStatusResolution",
46
+ "BindingStatusProjection",
47
+ "BindingScopeMismatch",
48
+ "ChatLabel",
49
+ "ChatLabelProvider",
50
+ "ChatLabelResolver",
51
+ "ClosedSide",
52
+ "CreatedBinding",
53
+ "CurrentBindingChanged",
54
+ "CurrentBindingTarget",
55
+ "CurrentSideTarget",
56
+ "ExactBindingTarget",
57
+ "InstanceManagementService",
58
+ "ManagementError",
59
+ "ManagementRuntimePort",
60
+ "NativeCatalogInconsistent",
61
+ "NativeThreadMissing",
62
+ "NativeThreadView",
63
+ "NoCurrentBinding",
64
+ "ProjectInventoryItem",
65
+ "ProjectInventoryPage",
66
+ "ProjectDeletionResult",
67
+ "ReleasedBinding",
68
+ "RenamedBinding",
69
+ "RuntimePrecondition",
70
+ "RuntimeSnapshots",
71
+ "RuntimeStateChanged",
72
+ "ScopeCoordinator",
73
+ "SessionInventoryItem",
74
+ "SessionInventoryPage",
75
+ "SessionInventoryState",
76
+ "SessionQuery",
77
+ "ScopeSessionPage",
78
+ "SideIdentityMismatch",
79
+ "SideTopicInventoryItem",
80
+ "SideTopicInventoryPage",
81
+ "StoppedBinding",
82
+ "classify_native_thread_view",
83
+ )
@@ -0,0 +1,352 @@
1
+ """Bounded execution for blocking management-plane filesystem work.
2
+
3
+ The executor deliberately counts submitted ``concurrent.futures.Future``
4
+ objects rather than asyncio Tasks. Cancelling an HTTP handler therefore only
5
+ stops that handler from waiting; it neither cancels the blocking operation nor
6
+ returns its admission slot early.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import asyncio
12
+ import math
13
+ import threading
14
+ from concurrent.futures import Future, ThreadPoolExecutor
15
+ from functools import partial
16
+ from typing import Callable, TypeVar
17
+
18
+
19
+ ResultT = TypeVar("ResultT")
20
+
21
+
22
+ class BlockingIOExecutorClosed(RuntimeError):
23
+ """Raised when work is submitted after admission has closed."""
24
+
25
+
26
+ class BlockingIOExecutorSaturated(RuntimeError):
27
+ """Raised when all running and queued work slots are occupied."""
28
+
29
+
30
+ class BlockingIOResultUnknown(RuntimeError):
31
+ """A submitted operation did not finish before its response deadline.
32
+
33
+ The underlying operation is not cancelled and remains tracked by the
34
+ executor. Callers must reconcile the authoritative state; they must not
35
+ infer rollback or retry the operation automatically.
36
+ """
37
+
38
+ def __init__(self, *, deadline: float) -> None:
39
+ self.deadline = deadline
40
+ super().__init__(
41
+ "blocking I/O deadline expired after submission; result is unknown "
42
+ "and the operation remains tracked"
43
+ )
44
+
45
+
46
+ class BlockingIODrainTimeout(TimeoutError):
47
+ """Raised when tracked work cannot drain within the caller's budget."""
48
+
49
+ def __init__(self, *, in_flight: int, deadline: float) -> None:
50
+ self.in_flight = in_flight
51
+ self.deadline = deadline
52
+ super().__init__(
53
+ f"blocking I/O drain deadline expired with {in_flight} operation(s) "
54
+ "still tracked"
55
+ )
56
+
57
+
58
+ class BlockingIOShutdownTimeout(TimeoutError):
59
+ """Raised when the worker-thread join exceeds the caller's budget."""
60
+
61
+ def __init__(self, *, deadline: float) -> None:
62
+ self.deadline = deadline
63
+ super().__init__("blocking I/O worker shutdown exceeded its deadline")
64
+
65
+
66
+ class BoundedBlockingIOExecutor:
67
+ """Run synchronous callables without an unbounded executor queue.
68
+
69
+ ``capacity`` counts both running and queued operations. Admission is
70
+ rejected synchronously within :meth:`submit` before
71
+ :class:`ThreadPoolExecutor` sees the callable. The instance belongs to one
72
+ asyncio event loop, while its completion accounting is protected for
73
+ callbacks arriving from worker threads.
74
+
75
+ ``deadline`` values are absolute values from the owning loop's monotonic
76
+ clock (``loop.time()``), which is suitable for sharing one shutdown or
77
+ request budget across several awaits.
78
+ """
79
+
80
+ def __init__(
81
+ self,
82
+ *,
83
+ max_workers: int = 1,
84
+ capacity: int = 2,
85
+ thread_name_prefix: str = "netizen-project-io",
86
+ ) -> None:
87
+ if (
88
+ isinstance(max_workers, bool)
89
+ or not isinstance(max_workers, int)
90
+ or max_workers < 1
91
+ ):
92
+ raise ValueError("max_workers must be a positive integer")
93
+ if (
94
+ isinstance(capacity, bool)
95
+ or not isinstance(capacity, int)
96
+ or capacity < max_workers
97
+ ):
98
+ raise ValueError("capacity must be an integer at least max_workers")
99
+ if not thread_name_prefix:
100
+ raise ValueError("thread_name_prefix must not be empty")
101
+
102
+ self._capacity = capacity
103
+ self._executor = ThreadPoolExecutor(
104
+ max_workers=max_workers,
105
+ thread_name_prefix=thread_name_prefix,
106
+ )
107
+ self._state_lock = threading.Lock()
108
+ self._tracked: set[Future[object]] = set()
109
+ self._accepting = True
110
+ self._shutdown_started = False
111
+
112
+ self._loop: asyncio.AbstractEventLoop | None = None
113
+ self._completion_event: asyncio.Event | None = None
114
+ self._join_task: asyncio.Task[None] | None = None
115
+ self._join_error: BaseException | None = None
116
+ self._joined = False
117
+
118
+ @property
119
+ def capacity(self) -> int:
120
+ return self._capacity
121
+
122
+ @property
123
+ def accepting(self) -> bool:
124
+ with self._state_lock:
125
+ return self._accepting
126
+
127
+ @property
128
+ def in_flight(self) -> int:
129
+ """Return the number of running or queued underlying operations."""
130
+
131
+ with self._state_lock:
132
+ return len(self._tracked)
133
+
134
+ @property
135
+ def joined(self) -> bool:
136
+ return self._joined
137
+
138
+ async def submit(
139
+ self,
140
+ function: Callable[..., ResultT],
141
+ /,
142
+ *args: object,
143
+ deadline: float | None = None,
144
+ **kwargs: object,
145
+ ) -> ResultT:
146
+ """Submit a synchronous callable and asynchronously await its result.
147
+
148
+ A deadline applies only to waiting for the already-submitted operation.
149
+ Expiry raises :class:`BlockingIOResultUnknown`; it never cancels or
150
+ retries the underlying future.
151
+ """
152
+
153
+ loop, _ = self._bind_to_running_loop()
154
+ checked_deadline = _validate_deadline(deadline)
155
+ operation = partial(function, *args, **kwargs)
156
+
157
+ with self._state_lock:
158
+ if not self._accepting:
159
+ raise BlockingIOExecutorClosed(
160
+ "blocking I/O executor is closed to new work"
161
+ )
162
+ if len(self._tracked) >= self._capacity:
163
+ raise BlockingIOExecutorSaturated(
164
+ "blocking I/O executor has no free submission slots"
165
+ )
166
+ # This is the only call into ThreadPoolExecutor.submit. Keeping it
167
+ # in the same critical section as the checks guarantees that a
168
+ # rejected operation never reaches its unbounded internal queue.
169
+ future = self._executor.submit(operation)
170
+ self._tracked.add(future)
171
+
172
+ future.add_done_callback(self._operation_completed)
173
+ wrapped = asyncio.wrap_future(future, loop=loop)
174
+ # If the request is cancelled or times out, nobody may await ``wrapped``
175
+ # again. Retrieve a later exception to prevent an asyncio warning;
176
+ # this does not change the result seen by a normal waiter.
177
+ wrapped.add_done_callback(_consume_asyncio_future_exception)
178
+
179
+ if checked_deadline is None:
180
+ return await asyncio.shield(wrapped)
181
+
182
+ remaining = checked_deadline - loop.time()
183
+ if remaining > 0:
184
+ done, _ = await asyncio.wait((wrapped,), timeout=remaining)
185
+ if done:
186
+ return wrapped.result()
187
+
188
+ # Cover completion racing the timeout without claiming uncertainty
189
+ # when the concurrent result is already synchronously available.
190
+ if future.done():
191
+ return future.result()
192
+ raise BlockingIOResultUnknown(deadline=checked_deadline)
193
+
194
+ def close_admission(self) -> None:
195
+ """Idempotently reject all future submissions without cancelling work."""
196
+
197
+ with self._state_lock:
198
+ self._accepting = False
199
+
200
+ async def drain(self, *, deadline: float | None = None) -> None:
201
+ """Wait for all tracked operations within an optional absolute deadline."""
202
+
203
+ loop, completion_event = self._bind_to_running_loop()
204
+ checked_deadline = _validate_deadline(deadline)
205
+
206
+ while True:
207
+ # Clear before checking while the worker callback cannot acquire
208
+ # the state lock. A completion after the check schedules a fresh
209
+ # set(), so no wakeup can be lost.
210
+ completion_event.clear()
211
+ with self._state_lock:
212
+ remaining_work = len(self._tracked)
213
+ if remaining_work == 0:
214
+ return
215
+
216
+ if checked_deadline is None:
217
+ await completion_event.wait()
218
+ continue
219
+
220
+ remaining_time = checked_deadline - loop.time()
221
+ if remaining_time <= 0:
222
+ raise BlockingIODrainTimeout(
223
+ in_flight=remaining_work,
224
+ deadline=checked_deadline,
225
+ )
226
+ try:
227
+ await asyncio.wait_for(
228
+ completion_event.wait(),
229
+ timeout=remaining_time,
230
+ )
231
+ except TimeoutError:
232
+ with self._state_lock:
233
+ remaining_work = len(self._tracked)
234
+ if remaining_work == 0:
235
+ return
236
+ raise BlockingIODrainTimeout(
237
+ in_flight=remaining_work,
238
+ deadline=checked_deadline,
239
+ ) from None
240
+
241
+ async def aclose(self, *, deadline: float | None = None) -> None:
242
+ """Close admission, drain tracked work, and join worker threads.
243
+
244
+ The operation is idempotent. Cancellation or deadline expiry leaves
245
+ admission closed and the submitted work running to completion; a later
246
+ call can finish draining and joining it.
247
+ """
248
+
249
+ loop, _ = self._bind_to_running_loop()
250
+ checked_deadline = _validate_deadline(deadline)
251
+ self.close_admission()
252
+ self._start_executor_shutdown()
253
+
254
+ await self.drain(deadline=checked_deadline)
255
+ if self._joined:
256
+ if self._join_error is not None:
257
+ raise self._join_error
258
+ return
259
+
260
+ join_task = self._join_task
261
+ if join_task is None:
262
+ join_task = asyncio.create_task(
263
+ asyncio.to_thread(
264
+ self._executor.shutdown,
265
+ wait=True,
266
+ cancel_futures=False,
267
+ ),
268
+ name="netizen-blocking-io-shutdown",
269
+ )
270
+ join_task.add_done_callback(self._executor_joined)
271
+ self._join_task = join_task
272
+
273
+ if checked_deadline is None:
274
+ await asyncio.shield(join_task)
275
+ else:
276
+ remaining = checked_deadline - loop.time()
277
+ if remaining <= 0:
278
+ if not join_task.done():
279
+ raise BlockingIOShutdownTimeout(deadline=checked_deadline)
280
+ else:
281
+ done, _ = await asyncio.wait((join_task,), timeout=remaining)
282
+ if not done:
283
+ raise BlockingIOShutdownTimeout(deadline=checked_deadline)
284
+
285
+ # The callback consumes and records exceptions for deadline/cancelled
286
+ # callers; result() here preserves normal close failure propagation.
287
+ join_task.result()
288
+
289
+ def _bind_to_running_loop(
290
+ self,
291
+ ) -> tuple[asyncio.AbstractEventLoop, asyncio.Event]:
292
+ loop = asyncio.get_running_loop()
293
+ with self._state_lock:
294
+ if self._loop is None:
295
+ self._loop = loop
296
+ self._completion_event = asyncio.Event()
297
+ elif self._loop is not loop:
298
+ raise RuntimeError(
299
+ "BoundedBlockingIOExecutor cannot be used across event loops"
300
+ )
301
+ completion_event = self._completion_event
302
+ assert completion_event is not None
303
+ return loop, completion_event
304
+
305
+ def _operation_completed(self, future: Future[object]) -> None:
306
+ with self._state_lock:
307
+ self._tracked.discard(future)
308
+ loop = self._loop
309
+ completion_event = self._completion_event
310
+ if loop is None or completion_event is None:
311
+ return
312
+ try:
313
+ loop.call_soon_threadsafe(completion_event.set)
314
+ except RuntimeError:
315
+ # The service is expected to drain before closing its event loop.
316
+ # Accounting is already correct even if a broken shutdown cannot
317
+ # be woken after the loop itself has disappeared.
318
+ pass
319
+
320
+ def _start_executor_shutdown(self) -> None:
321
+ with self._state_lock:
322
+ if self._shutdown_started:
323
+ return
324
+ self._shutdown_started = True
325
+ # wait=False never cancels queued work and returns immediately. A
326
+ # later off-loop wait=True call performs the clean thread join.
327
+ self._executor.shutdown(wait=False, cancel_futures=False)
328
+
329
+ def _executor_joined(self, task: asyncio.Task[None]) -> None:
330
+ try:
331
+ task.result()
332
+ except BaseException as error:
333
+ self._join_error = error
334
+ else:
335
+ self._joined = True
336
+
337
+
338
+ def _validate_deadline(deadline: float | None) -> float | None:
339
+ if deadline is None:
340
+ return None
341
+ if isinstance(deadline, bool) or not isinstance(deadline, (int, float)):
342
+ raise TypeError("deadline must be a finite monotonic timestamp")
343
+ checked = float(deadline)
344
+ if not math.isfinite(checked):
345
+ raise ValueError("deadline must be a finite monotonic timestamp")
346
+ return checked
347
+
348
+
349
+ def _consume_asyncio_future_exception(future: asyncio.Future[object]) -> None:
350
+ if future.cancelled():
351
+ return
352
+ future.exception()
@@ -0,0 +1,266 @@
1
+ """Best-effort Feishu chat labels for management inventory pages."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import time
7
+ from collections import OrderedDict
8
+ from collections.abc import Callable, Iterable, Sequence
9
+ from dataclasses import dataclass
10
+ from typing import Any, Protocol
11
+
12
+
13
+ _POSITIVE_TTL_SECONDS = 10 * 60.0
14
+ _NEGATIVE_TTL_SECONDS = 30.0
15
+ _CACHE_CAPACITY = 4_096
16
+ _LOOKUP_CONCURRENCY = 10
17
+ _LOOKUP_BUDGET_SECONDS = 2.5
18
+
19
+
20
+ class ChatLabelProvider(Protocol):
21
+ async def get_chat_info(self, chat_id: str) -> Any: ...
22
+
23
+ async def get_chat_members(
24
+ self,
25
+ chat_id: str,
26
+ *,
27
+ page_size: int = 100,
28
+ max_pages: int = 10,
29
+ id_type: str = "open_id",
30
+ force: bool = False,
31
+ ) -> Sequence[Any]: ...
32
+
33
+
34
+ @dataclass(frozen=True, slots=True)
35
+ class ChatLabel:
36
+ chat_id: str
37
+ display_name: str
38
+ chat_mode: str | None
39
+ resolved: bool = True
40
+ p2p_target_open_id: str | None = None
41
+ chat_type: str | None = None
42
+
43
+
44
+ @dataclass(frozen=True, slots=True)
45
+ class _CacheEntry:
46
+ label: ChatLabel
47
+ expires_at: float
48
+
49
+
50
+ class ChatLabelResolver:
51
+ """Resolve only requested chat IDs with bounded TTL/LRU caching."""
52
+
53
+ def __init__(
54
+ self,
55
+ provider: ChatLabelProvider,
56
+ *,
57
+ clock: Callable[[], float] = time.monotonic,
58
+ positive_ttl_seconds: float = _POSITIVE_TTL_SECONDS,
59
+ negative_ttl_seconds: float = _NEGATIVE_TTL_SECONDS,
60
+ capacity: int = _CACHE_CAPACITY,
61
+ concurrency: int = _LOOKUP_CONCURRENCY,
62
+ lookup_budget_seconds: float = _LOOKUP_BUDGET_SECONDS,
63
+ ) -> None:
64
+ if positive_ttl_seconds <= 0 or negative_ttl_seconds <= 0:
65
+ raise ValueError("chat label TTLs must be positive")
66
+ if capacity < 1:
67
+ raise ValueError("chat label cache capacity must be positive")
68
+ if concurrency < 1:
69
+ raise ValueError("chat label lookup concurrency must be positive")
70
+ if lookup_budget_seconds <= 0:
71
+ raise ValueError("chat label lookup budget must be positive")
72
+ self._provider = provider
73
+ self._clock = clock
74
+ self._positive_ttl_seconds = positive_ttl_seconds
75
+ self._negative_ttl_seconds = negative_ttl_seconds
76
+ self._capacity = capacity
77
+ self._semaphore = asyncio.Semaphore(concurrency)
78
+ self._lookup_budget_seconds = lookup_budget_seconds
79
+ self._cache: OrderedDict[str, _CacheEntry] = OrderedDict()
80
+ self._inflight: dict[str, asyncio.Task[ChatLabel]] = {}
81
+
82
+ async def resolve_many(
83
+ self,
84
+ chat_ids: Iterable[str],
85
+ *,
86
+ deadline: float,
87
+ ) -> dict[str, ChatLabel]:
88
+ unique = tuple(dict.fromkeys(chat_ids))
89
+ labels: dict[str, ChatLabel] = {}
90
+ tasks: dict[str, asyncio.Task[ChatLabel]] = {}
91
+ loop = asyncio.get_running_loop()
92
+ lookup_deadline = min(deadline, loop.time() + self._lookup_budget_seconds)
93
+
94
+ for chat_id in unique:
95
+ cached = self._get_cached(chat_id)
96
+ if cached is not None:
97
+ labels[chat_id] = cached
98
+ continue
99
+ task = self._inflight.get(chat_id)
100
+ if task is None:
101
+ task = asyncio.create_task(
102
+ self._resolve_one(chat_id, deadline=lookup_deadline)
103
+ )
104
+ self._inflight[chat_id] = task
105
+ task.add_done_callback(
106
+ lambda completed, resolved_id=chat_id: self._finish(
107
+ resolved_id,
108
+ completed,
109
+ )
110
+ )
111
+ tasks[chat_id] = task
112
+
113
+ if tasks:
114
+ timeout = max(0.0, lookup_deadline - loop.time())
115
+ await asyncio.wait(set(tasks.values()), timeout=timeout)
116
+ for chat_id, task in tasks.items():
117
+ if not task.done() or task.cancelled():
118
+ labels[chat_id] = self.fallback(chat_id)
119
+ continue
120
+ try:
121
+ labels[chat_id] = task.result()
122
+ except Exception:
123
+ labels[chat_id] = self.fallback(chat_id)
124
+
125
+ return labels
126
+
127
+ async def aclose(self) -> None:
128
+ tasks = tuple(self._inflight.values())
129
+ for task in tasks:
130
+ task.cancel()
131
+ if tasks:
132
+ await asyncio.gather(*tasks, return_exceptions=True)
133
+ self._inflight.clear()
134
+ self._cache.clear()
135
+
136
+ @staticmethod
137
+ def fallback(
138
+ chat_id: str,
139
+ *,
140
+ chat_mode: str | None = None,
141
+ chat_type: str | None = None,
142
+ p2p_target_open_id: str | None = None,
143
+ ) -> ChatLabel:
144
+ return ChatLabel(
145
+ chat_id=chat_id,
146
+ display_name=chat_id,
147
+ chat_mode=chat_mode,
148
+ resolved=False,
149
+ p2p_target_open_id=p2p_target_open_id,
150
+ chat_type=chat_type,
151
+ )
152
+
153
+ def _get_cached(self, chat_id: str) -> ChatLabel | None:
154
+ entry = self._cache.get(chat_id)
155
+ if entry is None:
156
+ return None
157
+ if entry.expires_at <= self._clock():
158
+ del self._cache[chat_id]
159
+ return None
160
+ self._cache.move_to_end(chat_id)
161
+ return entry.label
162
+
163
+ def _finish(self, chat_id: str, task: asyncio.Task[ChatLabel]) -> None:
164
+ if self._inflight.get(chat_id) is task:
165
+ del self._inflight[chat_id]
166
+ if task.cancelled():
167
+ return
168
+ try:
169
+ label = task.result()
170
+ except Exception:
171
+ label = self.fallback(chat_id)
172
+ ttl = (
173
+ self._positive_ttl_seconds
174
+ if label.resolved
175
+ else self._negative_ttl_seconds
176
+ )
177
+ self._cache[chat_id] = _CacheEntry(label, self._clock() + ttl)
178
+ self._cache.move_to_end(chat_id)
179
+ while len(self._cache) > self._capacity:
180
+ self._cache.popitem(last=False)
181
+
182
+ async def _resolve_one(self, chat_id: str, *, deadline: float) -> ChatLabel:
183
+ try:
184
+ async with self._semaphore:
185
+ info = await self._before_deadline(
186
+ self._provider.get_chat_info(chat_id),
187
+ deadline=deadline,
188
+ )
189
+ if info is None:
190
+ return self.fallback(chat_id)
191
+ name = self._text(getattr(info, "name", None))
192
+ chat_mode = self._text(getattr(info, "chat_mode", None))
193
+ chat_type = self._text(getattr(info, "chat_type", None))
194
+ p2p_target_open_id = None
195
+ if chat_mode == "p2p":
196
+ try:
197
+ members = await self._before_deadline(
198
+ self._provider.get_chat_members(
199
+ chat_id,
200
+ page_size=100,
201
+ max_pages=1,
202
+ id_type="open_id",
203
+ ),
204
+ deadline=deadline,
205
+ )
206
+ except asyncio.CancelledError:
207
+ raise
208
+ except Exception:
209
+ if not name:
210
+ return self.fallback(
211
+ chat_id,
212
+ chat_mode=chat_mode,
213
+ chat_type=chat_type,
214
+ )
215
+ members = ()
216
+ member_name, p2p_target_open_id = self._p2p_member(members)
217
+ name = member_name or name
218
+ if not name:
219
+ return self.fallback(
220
+ chat_id,
221
+ chat_mode=chat_mode,
222
+ chat_type=chat_type,
223
+ p2p_target_open_id=p2p_target_open_id,
224
+ )
225
+ return ChatLabel(
226
+ chat_id=chat_id,
227
+ display_name=name,
228
+ chat_mode=chat_mode,
229
+ resolved=True,
230
+ p2p_target_open_id=p2p_target_open_id,
231
+ chat_type=chat_type,
232
+ )
233
+ except asyncio.CancelledError:
234
+ raise
235
+ except TimeoutError:
236
+ return self.fallback(chat_id)
237
+ except Exception:
238
+ return self.fallback(chat_id)
239
+
240
+ @staticmethod
241
+ async def _before_deadline(awaitable: Any, *, deadline: float) -> Any:
242
+ remaining = deadline - asyncio.get_running_loop().time()
243
+ if remaining <= 0:
244
+ if hasattr(awaitable, "close"):
245
+ awaitable.close()
246
+ raise TimeoutError("chat label lookup deadline elapsed")
247
+ return await asyncio.wait_for(awaitable, timeout=remaining)
248
+
249
+ @classmethod
250
+ def _p2p_member(cls, members: Sequence[Any]) -> tuple[str, str | None]:
251
+ candidates: list[tuple[str, str | None]] = []
252
+ for member in members:
253
+ name = cls._text(getattr(member, "name", None))
254
+ member_id = cls._text(getattr(member, "id", None))
255
+ id_type = cls._text(getattr(member, "id_type", None))
256
+ if name:
257
+ candidates.append(
258
+ (name, member_id if id_type == "open_id" else None)
259
+ )
260
+ if len(candidates) != 1:
261
+ return "", None
262
+ return candidates[0]
263
+
264
+ @staticmethod
265
+ def _text(value: Any) -> str:
266
+ return value.strip() if isinstance(value, str) else ""