tlgr-cli 2.0.1__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 (192) hide show
  1. tlgr/__init__.py +3 -0
  2. tlgr/__main__.py +6 -0
  3. tlgr/actions/__init__.py +45 -0
  4. tlgr/actions/forward.py +74 -0
  5. tlgr/actions/reply.py +32 -0
  6. tlgr/cli/__init__.py +259 -0
  7. tlgr/cli/confirm.py +55 -0
  8. tlgr/cli/errors.py +84 -0
  9. tlgr/cli/gen.py +690 -0
  10. tlgr/cli/globals.py +273 -0
  11. tlgr/cli/introspect.py +170 -0
  12. tlgr/cli/params.py +189 -0
  13. tlgr/cli/render.py +418 -0
  14. tlgr/core/__init__.py +0 -0
  15. tlgr/core/accounts.py +384 -0
  16. tlgr/core/config.py +358 -0
  17. tlgr/core/custom_tl.py +170 -0
  18. tlgr/core/errors.py +687 -0
  19. tlgr/core/eventtypes.py +1170 -0
  20. tlgr/core/identity.py +127 -0
  21. tlgr/core/launchd.py +122 -0
  22. tlgr/core/logging.py +194 -0
  23. tlgr/core/media.py +134 -0
  24. tlgr/core/output.py +251 -0
  25. tlgr/core/pagination.py +227 -0
  26. tlgr/core/paths.py +360 -0
  27. tlgr/core/peers.py +427 -0
  28. tlgr/core/process.py +138 -0
  29. tlgr/core/signing.py +38 -0
  30. tlgr/core/systemd.py +96 -0
  31. tlgr/core/telethon_compat.py +295 -0
  32. tlgr/core/text.py +211 -0
  33. tlgr/core/timefmt.py +199 -0
  34. tlgr/core/tl.py +98 -0
  35. tlgr/daemon/__init__.py +0 -0
  36. tlgr/daemon/app.py +869 -0
  37. tlgr/daemon/dispatch.py +446 -0
  38. tlgr/daemon/events.py +723 -0
  39. tlgr/daemon/files.py +431 -0
  40. tlgr/daemon/idle.py +119 -0
  41. tlgr/daemon/jobs.py +68 -0
  42. tlgr/daemon/main.py +161 -0
  43. tlgr/daemon/peercred.py +75 -0
  44. tlgr/daemon/policy.py +113 -0
  45. tlgr/daemon/preauth.py +366 -0
  46. tlgr/daemon/ratelimit.py +391 -0
  47. tlgr/daemon/server.py +24 -0
  48. tlgr/daemon/session.py +648 -0
  49. tlgr/daemon/sessions.py +274 -0
  50. tlgr/daemon/singleton.py +114 -0
  51. tlgr/daemon/stream.py +193 -0
  52. tlgr/daemon/transfers.py +219 -0
  53. tlgr/daemon/webhook.py +390 -0
  54. tlgr/data/catalog_index.json +1 -0
  55. tlgr/data/parity_waivers.toml +90 -0
  56. tlgr/filters/__init__.py +42 -0
  57. tlgr/filters/compose.py +121 -0
  58. tlgr/filters/content.py +85 -0
  59. tlgr/filters/context.py +114 -0
  60. tlgr/filters/message.py +161 -0
  61. tlgr/filters/temporal.py +87 -0
  62. tlgr/filters/user.py +36 -0
  63. tlgr/gateway/__init__.py +1 -0
  64. tlgr/gateway/config.py +161 -0
  65. tlgr/gateway/engine.py +215 -0
  66. tlgr/gateway/event.py +22 -0
  67. tlgr/jobs/__init__.py +0 -0
  68. tlgr/jobs/base.py +81 -0
  69. tlgr/jobs/client.py +37 -0
  70. tlgr/models/__init__.py +1220 -0
  71. tlgr/models/admin.py +744 -0
  72. tlgr/models/auth.py +510 -0
  73. tlgr/models/base.py +81 -0
  74. tlgr/models/bot.py +576 -0
  75. tlgr/models/business.py +265 -0
  76. tlgr/models/call.py +586 -0
  77. tlgr/models/config.py +101 -0
  78. tlgr/models/contact.py +481 -0
  79. tlgr/models/daemon.py +336 -0
  80. tlgr/models/dialog.py +626 -0
  81. tlgr/models/envelope.py +68 -0
  82. tlgr/models/error.py +30 -0
  83. tlgr/models/event.py +79 -0
  84. tlgr/models/export.py +66 -0
  85. tlgr/models/gift.py +275 -0
  86. tlgr/models/inline.py +84 -0
  87. tlgr/models/location.py +115 -0
  88. tlgr/models/media.py +507 -0
  89. tlgr/models/message.py +584 -0
  90. tlgr/models/net.py +232 -0
  91. tlgr/models/notify.py +105 -0
  92. tlgr/models/page.py +32 -0
  93. tlgr/models/payment.py +172 -0
  94. tlgr/models/peer.py +400 -0
  95. tlgr/models/poll.py +119 -0
  96. tlgr/models/premium.py +161 -0
  97. tlgr/models/privacy.py +93 -0
  98. tlgr/models/profile.py +217 -0
  99. tlgr/models/reaction.py +160 -0
  100. tlgr/models/resolve.py +175 -0
  101. tlgr/models/settings.py +103 -0
  102. tlgr/models/stars.py +101 -0
  103. tlgr/models/sticker.py +243 -0
  104. tlgr/models/story.py +467 -0
  105. tlgr/models/sync.py +105 -0
  106. tlgr/models/todo.py +36 -0
  107. tlgr/models/webapp.py +89 -0
  108. tlgr/ops/__init__.py +63 -0
  109. tlgr/ops/_admin.py +313 -0
  110. tlgr/ops/_auth.py +599 -0
  111. tlgr/ops/_bots.py +586 -0
  112. tlgr/ops/_calls.py +535 -0
  113. tlgr/ops/_common.py +160 -0
  114. tlgr/ops/_layer.py +46 -0
  115. tlgr/ops/_media.py +592 -0
  116. tlgr/ops/_params.py +212 -0
  117. tlgr/ops/_rights.py +402 -0
  118. tlgr/ops/_send.py +593 -0
  119. tlgr/ops/_serialize.py +667 -0
  120. tlgr/ops/_settings.py +306 -0
  121. tlgr/ops/_spec.py +167 -0
  122. tlgr/ops/_story.py +743 -0
  123. tlgr/ops/account.py +2604 -0
  124. tlgr/ops/agent.py +937 -0
  125. tlgr/ops/auth.py +1282 -0
  126. tlgr/ops/bot.py +4880 -0
  127. tlgr/ops/business.py +1520 -0
  128. tlgr/ops/call.py +1610 -0
  129. tlgr/ops/chat.py +4025 -0
  130. tlgr/ops/chat_admin.py +929 -0
  131. tlgr/ops/chat_extra.py +1061 -0
  132. tlgr/ops/chat_invite.py +716 -0
  133. tlgr/ops/chat_manage.py +1691 -0
  134. tlgr/ops/chat_member.py +1357 -0
  135. tlgr/ops/chat_stats.py +902 -0
  136. tlgr/ops/chat_topic.py +905 -0
  137. tlgr/ops/conference.py +791 -0
  138. tlgr/ops/config.py +1698 -0
  139. tlgr/ops/contact.py +2330 -0
  140. tlgr/ops/daemon.py +1397 -0
  141. tlgr/ops/draft.py +299 -0
  142. tlgr/ops/emoji.py +343 -0
  143. tlgr/ops/events.py +1327 -0
  144. tlgr/ops/export.py +596 -0
  145. tlgr/ops/folder.py +1322 -0
  146. tlgr/ops/gif.py +522 -0
  147. tlgr/ops/gift.py +1546 -0
  148. tlgr/ops/giveaway.py +541 -0
  149. tlgr/ops/inline.py +773 -0
  150. tlgr/ops/job.py +799 -0
  151. tlgr/ops/location.py +917 -0
  152. tlgr/ops/media.py +4495 -0
  153. tlgr/ops/message.py +3769 -0
  154. tlgr/ops/net.py +536 -0
  155. tlgr/ops/notify.py +840 -0
  156. tlgr/ops/passport.py +464 -0
  157. tlgr/ops/payment.py +907 -0
  158. tlgr/ops/poll.py +1078 -0
  159. tlgr/ops/premium.py +488 -0
  160. tlgr/ops/privacy.py +794 -0
  161. tlgr/ops/profile.py +1481 -0
  162. tlgr/ops/proxy.py +750 -0
  163. tlgr/ops/reaction.py +1475 -0
  164. tlgr/ops/resolve.py +1140 -0
  165. tlgr/ops/search.py +521 -0
  166. tlgr/ops/settings.py +1066 -0
  167. tlgr/ops/stars.py +594 -0
  168. tlgr/ops/sticker.py +1602 -0
  169. tlgr/ops/story.py +3216 -0
  170. tlgr/ops/sync.py +788 -0
  171. tlgr/ops/todo.py +514 -0
  172. tlgr/ops/user.py +1406 -0
  173. tlgr/ops/vc.py +2351 -0
  174. tlgr/ops/webapp.py +717 -0
  175. tlgr/ops/webhook.py +418 -0
  176. tlgr/parity.py +386 -0
  177. tlgr/processors/__init__.py +125 -0
  178. tlgr/processors/regex.py +26 -0
  179. tlgr/processors/text.py +56 -0
  180. tlgr/registry.py +519 -0
  181. tlgr/schema.py +173 -0
  182. tlgr/transport/__init__.py +30 -0
  183. tlgr/transport/autostart.py +293 -0
  184. tlgr/transport/client.py +805 -0
  185. tlgr/transport/ndjson.py +44 -0
  186. tlgr/version.py +31 -0
  187. tlgr_cli-2.0.1.dist-info/METADATA +957 -0
  188. tlgr_cli-2.0.1.dist-info/RECORD +192 -0
  189. tlgr_cli-2.0.1.dist-info/WHEEL +5 -0
  190. tlgr_cli-2.0.1.dist-info/entry_points.txt +2 -0
  191. tlgr_cli-2.0.1.dist-info/licenses/LICENSE +21 -0
  192. tlgr_cli-2.0.1.dist-info/top_level.txt +1 -0
@@ -0,0 +1,446 @@
1
+ """`POST /v1/op`: decode → policy → account → dry-run → timeout → impl (§5.2).
2
+
3
+ One function, in one order, for every operation. That is the whole point of
4
+ the registry: v1 had 37 hand-written handlers, so `--dry-run` was honoured by
5
+ nine of them (COR-17), the account was resolved differently in three of them
6
+ (COR-02), the policy allowlist was checked by none of them (SEC-04), and a
7
+ timeout existed in exactly zero (ROB-03).
8
+
9
+ The order below is not arbitrary:
10
+
11
+ * **policy before account** — being told "that operation is not enabled"
12
+ should not depend on whether the account exists;
13
+ * **account before dry-run** — a dry run that names an account tlgr cannot
14
+ resolve is a lie about what would happen;
15
+ * **dry-run before the rate limiter** — a dry run must not spend a token or
16
+ wait on a flood deadline it is never going to hit;
17
+ * **the timeout wraps the impl only** — decoding and policy are not the slow
18
+ part, and counting them against the caller's budget makes the timeout mean
19
+ something different for a large request.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import asyncio
25
+ import contextlib
26
+ import logging
27
+ import time
28
+ from dataclasses import dataclass, field
29
+ from typing import TYPE_CHECKING, Any
30
+
31
+ import msgspec
32
+
33
+ from tlgr.core.errors import (
34
+ AccountRequiredError,
35
+ RetryableError,
36
+ UsageError,
37
+ )
38
+ from tlgr.models.base import to_builtins
39
+ from tlgr.models.envelope import OpRequest
40
+ from tlgr.ops._spec import OperationSpec, Surface
41
+
42
+ if TYPE_CHECKING: # pragma: no cover
43
+ from tlgr.daemon.app import Daemon
44
+
45
+ log = logging.getLogger("tlgr.daemon.dispatch")
46
+
47
+ __all__ = [
48
+ "DaemonContext",
49
+ "decode_request",
50
+ "dispatch",
51
+ "normalise_peer_refs",
52
+ "resolve_spec",
53
+ ]
54
+
55
+
56
+ @dataclass
57
+ class DaemonContext:
58
+ """What an operation implementation is handed inside the daemon.
59
+
60
+ Satisfies `ops._spec.OpContext` structurally, and adds the services an
61
+ implementation legitimately needs. Anything an op reaches for that is not
62
+ here is a sign the op is doing the daemon's job.
63
+ """
64
+
65
+ account: str
66
+ request_id: str
67
+ dry_run: bool = False
68
+ client: Any = None
69
+ session: Any = None
70
+ #: The per-account entity resolver (§6.6). Every peer an implementation
71
+ #: touches goes through this, so the NOT_FOUND / INDETERMINATE
72
+ #: distinction is made in one place rather than per operation.
73
+ resolver: Any = None
74
+ daemon: Any = None
75
+ limiter: Any = None
76
+ bus: Any = None
77
+ #: The daemon's transfer store (`daemon/transfers.py`), so `--background`,
78
+ #: `media transfer list/stop/retry` all see the same jobs.
79
+ transfers: Any = None
80
+ paths: Any = None
81
+ config: Any = None
82
+ flood_wait_max: int | None = None
83
+ limit: int | None = None
84
+ cursor: str | None = None
85
+ fetch_all: bool = False
86
+ warnings: list[str] = field(default_factory=list)
87
+ flood_wait_slept: int = 0
88
+ already: bool = False
89
+ #: Set when an operation could not *establish* its answer. The CLI turns
90
+ #: it into exit 13, so a truncated harvest or an unresolvable peer still
91
+ #: returns its partial result and still fails closed (§7.3).
92
+ indeterminate: bool = False
93
+ indeterminate_reason: str = ""
94
+
95
+ def warn(self, message: str) -> None:
96
+ self.warnings.append(message)
97
+
98
+ async def upload_file(self, path: Any, **kwargs: Any) -> Any:
99
+ """Upload a local file and return the `InputFile` handle.
100
+
101
+ The file pipeline lives in `daemon/files.py` and `ops/` may not import
102
+ `daemon/` (§2.2), so the daemon hands it over the same way it hands
103
+ over the resolver and the limiter: as a service on the context.
104
+ """
105
+ from pathlib import Path
106
+
107
+ from tlgr.daemon.files import UploadPlan, upload
108
+
109
+ return await upload(self.client, UploadPlan(source=Path(path), **kwargs))
110
+
111
+ async def download_file(
112
+ self,
113
+ location: Any,
114
+ target: Any,
115
+ *,
116
+ size: int = 0,
117
+ dc_id: int = 0,
118
+ offset: int = 0,
119
+ limit: int | None = None,
120
+ resume: bool = True,
121
+ part_size: int = 512 * 1024,
122
+ connections: int = 1,
123
+ refresh: Any = None,
124
+ progress: Any = None,
125
+ ) -> Any:
126
+ """Stream a file to disk through the daemon's pipeline.
127
+
128
+ The mirror image of `upload_file`, and for the same reason: resume,
129
+ the per-DC concurrency caps and the one-shot file-reference refresh
130
+ live in `daemon/files.py`, which `ops/` may not import (§2.2).
131
+ """
132
+ from pathlib import Path
133
+
134
+ from tlgr.daemon.files import DownloadPlan, download
135
+
136
+ plan = DownloadPlan(
137
+ target=Path(target),
138
+ size=size,
139
+ dc_id=dc_id,
140
+ offset=offset,
141
+ limit=limit,
142
+ resume=resume,
143
+ part_size=part_size,
144
+ connections=connections,
145
+ )
146
+ slots = getattr(self.daemon, "transfer_slots", None)
147
+ return await download(
148
+ self.client, location, plan, slots=slots, progress=progress, refresh=refresh
149
+ )
150
+
151
+ def mark_already(self) -> None:
152
+ """Record that the world already looked the way the caller asked for."""
153
+ self.already = True
154
+
155
+ def mark_indeterminate(self, reason: str = "") -> None:
156
+ """Record that the answer could not be established.
157
+
158
+ The result is still returned — a partial answer with a reason beats
159
+ an error that throws the partial answer away — and the CLI exits 13,
160
+ so a caller gating on it fails closed. `user dialog-status` is the
161
+ op this exists for: its three-valued contract needs both the body
162
+ and the non-zero status.
163
+ """
164
+ self.indeterminate = True
165
+ self.indeterminate_reason = reason
166
+
167
+ def emit(self, event_type: str, payload: dict[str, Any], **kwargs: Any) -> None:
168
+ """Echo an action tlgr itself performed onto the bus (§6.5).
169
+
170
+ Telethon does not dispatch `NewMessage` for our own sends, so without
171
+ this a `tlgr watch` never shows what tlgr just did and a gateway rule
172
+ cannot react to it.
173
+ """
174
+ if self.bus is not None:
175
+ self.bus.emit(self.account, event_type, payload, self_origin=True, **kwargs)
176
+
177
+
178
+ def decode_request(raw: bytes) -> OpRequest:
179
+ try:
180
+ return msgspec.json.decode(raw or b"{}", type=OpRequest)
181
+ except msgspec.ValidationError as exc:
182
+ raise UsageError(str(exc)) from exc
183
+ except msgspec.DecodeError as exc:
184
+ raise UsageError(f"request body is not JSON: {exc}") from exc
185
+
186
+
187
+ def resolve_spec(op_id: str) -> OperationSpec:
188
+ """Canonicalise an id or alias into its spec, or raise USAGE."""
189
+ from tlgr.registry import REGISTRY, canonical
190
+
191
+ try:
192
+ resolved = canonical(op_id)
193
+ except Exception as exc:
194
+ raise UsageError(f"unknown operation {op_id!r}", field="op") from exc
195
+ spec = REGISTRY.get(resolved)
196
+ if spec is None:
197
+ raise UsageError(f"unknown operation {op_id!r}", field="op")
198
+ return spec
199
+
200
+
201
+ def _peer_ref_fields(request: type[msgspec.Struct]) -> dict[str, bool]:
202
+ """`{field name: is a list}` for every field typed as a `PeerRef`."""
203
+ import types as pytypes
204
+ import typing
205
+
206
+ from tlgr.models.peer import PeerRef
207
+
208
+ out: dict[str, bool] = {}
209
+ for name, annotation in typing.get_type_hints(request, include_extras=False).items():
210
+ node = annotation
211
+ repeated = False
212
+ while True:
213
+ origin = typing.get_origin(node)
214
+ if origin in (typing.Union, pytypes.UnionType):
215
+ optional = [a for a in typing.get_args(node) if a is not type(None)]
216
+ node = optional[0] if optional else node
217
+ continue
218
+ if origin in (list, tuple, set):
219
+ inner = typing.get_args(node)
220
+ node = inner[0] if inner else node
221
+ repeated = True
222
+ continue
223
+ break
224
+ if node is PeerRef:
225
+ out[name] = repeated
226
+ return out
227
+
228
+
229
+ def normalise_peer_refs(spec: OperationSpec, payload: dict[str, Any]) -> dict[str, Any]:
230
+ """Let the wire accept `"@alice"` where the struct wants a `PeerRef`.
231
+
232
+ A `PeerRef` is a parsed shape, and the CLI parses it before the request
233
+ leaves. Anything else talking to `/v1/op` — an agent, a script, a test —
234
+ would otherwise have to reimplement `parse_peer_ref` to say "@alice",
235
+ which is exactly the kind of duplication that ends up disagreeing about
236
+ what a `-100…` id means. Parsing here keeps one parser.
237
+ """
238
+ from tlgr.models.peer import parse_peer_ref
239
+
240
+ fields = _peer_ref_fields(spec.request)
241
+ if not fields:
242
+ return payload
243
+
244
+ def parse(value: Any) -> Any:
245
+ if not isinstance(value, (str, int)):
246
+ return value
247
+ try:
248
+ return to_builtins(parse_peer_ref(str(value)))
249
+ except ValueError as exc:
250
+ raise UsageError(str(exc)) from exc
251
+
252
+ out = dict(payload)
253
+ for name, repeated in fields.items():
254
+ if name not in out or out[name] is None:
255
+ continue
256
+ value = out[name]
257
+ out[name] = [parse(item) for item in value] if repeated else parse(value)
258
+ return out
259
+
260
+
261
+ def decode_payload(spec: OperationSpec, payload: dict[str, Any]) -> Any:
262
+ """Decode the op's request struct, turning a mismatch into a field error.
263
+
264
+ msgspec ends a validation message with ` - at $.chat.kind`; that suffix is
265
+ what makes the error actionable, and `classify()` lifts it into
266
+ `error.field` for us.
267
+ """
268
+ payload = normalise_peer_refs(spec, payload)
269
+ try:
270
+ return msgspec.convert(payload, type=spec.request, strict=False)
271
+ except msgspec.ValidationError as exc:
272
+ raise UsageError(str(exc)) from exc
273
+
274
+
275
+ async def dispatch(daemon: Daemon, request: OpRequest) -> dict[str, Any]:
276
+ """Run one operation and return its success envelope."""
277
+ started = time.monotonic()
278
+ spec, context, result = await execute(daemon, request)
279
+ return _envelope(
280
+ spec, context.account, result, context, started, dry_run=context.dry_run and spec.mutating
281
+ )
282
+
283
+
284
+ async def execute(daemon: Daemon, request: OpRequest) -> tuple[OperationSpec, DaemonContext, Any]:
285
+ """The prologue and the implementation, without the envelope.
286
+
287
+ Split out so that a streaming response can take the *result* — which may
288
+ be an async page iterator — instead of a dict that has already been
289
+ flattened. Both paths therefore run the identical policy, account,
290
+ dry-run, rate-limit and timeout sequence; there is no second order to get
291
+ wrong.
292
+ """
293
+ spec = resolve_spec(request.op)
294
+
295
+ daemon.policy.enforce(spec.id, spec)
296
+
297
+ account = (request.account or "").strip()
298
+ if spec.needs_account and not account:
299
+ # The daemon never picks. v1 used "whichever alias came first out of a
300
+ # set", so a two-account user could send from the wrong identity with
301
+ # no signal at all (COR-02).
302
+ raise AccountRequiredError(
303
+ f"{spec.id} needs an account and none was given",
304
+ )
305
+
306
+ payload = decode_payload(spec, request.request)
307
+
308
+ context = DaemonContext(
309
+ account=account,
310
+ request_id=request.request_id,
311
+ dry_run=request.dry_run,
312
+ daemon=daemon,
313
+ bus=daemon.bus,
314
+ transfers=getattr(daemon, "transfers", None),
315
+ paths=daemon.paths,
316
+ config=daemon.config,
317
+ flood_wait_max=request.flood_wait_max,
318
+ limit=request.limit,
319
+ cursor=request.cursor,
320
+ fetch_all=request.all,
321
+ )
322
+
323
+ if request.dry_run and spec.mutating:
324
+ return spec, context, {"dry_run": True, "would": spec.id, "request": to_builtins(payload)}
325
+
326
+ if spec.surface is not Surface.LOCAL and spec.needs_account and spec.needs_client:
327
+ session = await daemon.sessions.ensure(account)
328
+ limiter = daemon.sessions.limiter(account)
329
+ limiter.check(rate_class=spec.rate_class)
330
+ context.session = session
331
+ context.limiter = limiter
332
+ context.client = await session.acquire(timeout=spec.timeout_s)
333
+ resolver = session.resolver
334
+ resolver.limiter = limiter
335
+ context.resolver = resolver
336
+ session.in_flight += 1
337
+ else:
338
+ session = None
339
+ # A daemon operation that needs no Telegram client still benefits from
340
+ # the resolver when the account happens to be connected — `watch
341
+ # --chat @alice` should not have to be given a numeric id just because
342
+ # reading the bus does not itself need a socket. Attached, never
343
+ # *connected*: asking the daemon a question about itself must not dial
344
+ # Telegram as a side effect.
345
+ if account and spec.surface is not Surface.LOCAL:
346
+ existing = daemon.sessions.get(account)
347
+ if existing is not None and existing.client is not None:
348
+ context.session = existing
349
+ context.client = existing.client
350
+ context.limiter = daemon.sessions.limiter(account)
351
+ context.resolver = existing.resolver
352
+
353
+ budget: Any = None
354
+ if session is not None and context.limiter is not None:
355
+ budget = context.limiter.sleep_budget(request.flood_wait_max, float(spec.timeout_s))
356
+ try:
357
+ if spec.rate_class != "local" and context.limiter is not None:
358
+ await context.limiter.acquire(spec.rate_class)
359
+ if spec.min_interval_s:
360
+ await asyncio.sleep(spec.min_interval_s)
361
+ with _budget(session, budget):
362
+ call = spec.impl(context, payload)
363
+ # A streaming implementation returns an async iterator rather than
364
+ # a coroutine; the caller walks it, and the timeout then bounds
365
+ # each page rather than the whole walk (which may legitimately
366
+ # take an hour).
367
+ if hasattr(call, "__aiter__"):
368
+ result: Any = call
369
+ else:
370
+ result = await asyncio.wait_for(call, timeout=spec.timeout_s)
371
+ except (TimeoutError, asyncio.TimeoutError) as exc:
372
+ raise RetryableError(
373
+ f"{spec.id} did not finish within {spec.timeout_s}s and was cancelled"
374
+ ) from exc
375
+ finally:
376
+ if session is not None:
377
+ session.in_flight = max(0, session.in_flight - 1)
378
+
379
+ return spec, context, result
380
+
381
+
382
+ def _envelope(
383
+ spec: OperationSpec,
384
+ account: str,
385
+ result: Any,
386
+ context: DaemonContext,
387
+ started: float,
388
+ *,
389
+ dry_run: bool = False,
390
+ ) -> dict[str, Any]:
391
+ from tlgr import __version__
392
+ from tlgr.version import PROTOCOL
393
+
394
+ body = to_builtins(result) if result is not None else None
395
+ envelope: dict[str, Any] = {
396
+ "ok": True,
397
+ "op": spec.id,
398
+ "account": account or None,
399
+ "result": body,
400
+ "meta": {
401
+ "request_id": context.request_id,
402
+ "elapsed_ms": int((time.monotonic() - started) * 1000),
403
+ "flood_wait_slept": context.flood_wait_slept,
404
+ "warnings": context.warnings,
405
+ "already": context.already,
406
+ "daemon_version": __version__,
407
+ "protocol": PROTOCOL,
408
+ },
409
+ }
410
+ if dry_run:
411
+ envelope["meta"]["dry_run"] = True
412
+ if context.indeterminate:
413
+ envelope["meta"]["indeterminate"] = True
414
+ if context.indeterminate_reason:
415
+ envelope["meta"]["reason"] = context.indeterminate_reason
416
+ # `omit_defaults` drops an empty `items`, so the membership test v1 used
417
+ # here made an empty page lose its `page` envelope entirely — the one
418
+ # shape a caller walking pages must be able to rely on.
419
+ if spec.paginated is not None and isinstance(body, dict):
420
+ envelope["result"] = body.get("items") or []
421
+ envelope["page"] = {
422
+ "has_more": bool(body.get("has_more")),
423
+ "next_cursor": body.get("next_cursor"),
424
+ "total": body.get("total"),
425
+ }
426
+ return envelope
427
+
428
+
429
+ @contextlib.contextmanager
430
+ def _budget(session: Any, seconds: int | None) -> Any:
431
+ """`session.flood_budget`, but a no-op for a local operation."""
432
+ if session is None or seconds is None:
433
+ yield
434
+ return
435
+ with session.flood_budget(seconds):
436
+ yield
437
+
438
+
439
+ @contextlib.contextmanager
440
+ def in_flight(daemon: Daemon) -> Any:
441
+ """Count a request for idle accounting, and always uncount it (COR-11)."""
442
+ daemon.activity.begin_request()
443
+ try:
444
+ yield
445
+ finally:
446
+ daemon.activity.end_request()