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.
- tlgr/__init__.py +3 -0
- tlgr/__main__.py +6 -0
- tlgr/actions/__init__.py +45 -0
- tlgr/actions/forward.py +74 -0
- tlgr/actions/reply.py +32 -0
- tlgr/cli/__init__.py +259 -0
- tlgr/cli/confirm.py +55 -0
- tlgr/cli/errors.py +84 -0
- tlgr/cli/gen.py +690 -0
- tlgr/cli/globals.py +273 -0
- tlgr/cli/introspect.py +170 -0
- tlgr/cli/params.py +189 -0
- tlgr/cli/render.py +418 -0
- tlgr/core/__init__.py +0 -0
- tlgr/core/accounts.py +384 -0
- tlgr/core/config.py +358 -0
- tlgr/core/custom_tl.py +170 -0
- tlgr/core/errors.py +687 -0
- tlgr/core/eventtypes.py +1170 -0
- tlgr/core/identity.py +127 -0
- tlgr/core/launchd.py +122 -0
- tlgr/core/logging.py +194 -0
- tlgr/core/media.py +134 -0
- tlgr/core/output.py +251 -0
- tlgr/core/pagination.py +227 -0
- tlgr/core/paths.py +360 -0
- tlgr/core/peers.py +427 -0
- tlgr/core/process.py +138 -0
- tlgr/core/signing.py +38 -0
- tlgr/core/systemd.py +96 -0
- tlgr/core/telethon_compat.py +295 -0
- tlgr/core/text.py +211 -0
- tlgr/core/timefmt.py +199 -0
- tlgr/core/tl.py +98 -0
- tlgr/daemon/__init__.py +0 -0
- tlgr/daemon/app.py +869 -0
- tlgr/daemon/dispatch.py +446 -0
- tlgr/daemon/events.py +723 -0
- tlgr/daemon/files.py +431 -0
- tlgr/daemon/idle.py +119 -0
- tlgr/daemon/jobs.py +68 -0
- tlgr/daemon/main.py +161 -0
- tlgr/daemon/peercred.py +75 -0
- tlgr/daemon/policy.py +113 -0
- tlgr/daemon/preauth.py +366 -0
- tlgr/daemon/ratelimit.py +391 -0
- tlgr/daemon/server.py +24 -0
- tlgr/daemon/session.py +648 -0
- tlgr/daemon/sessions.py +274 -0
- tlgr/daemon/singleton.py +114 -0
- tlgr/daemon/stream.py +193 -0
- tlgr/daemon/transfers.py +219 -0
- tlgr/daemon/webhook.py +390 -0
- tlgr/data/catalog_index.json +1 -0
- tlgr/data/parity_waivers.toml +90 -0
- tlgr/filters/__init__.py +42 -0
- tlgr/filters/compose.py +121 -0
- tlgr/filters/content.py +85 -0
- tlgr/filters/context.py +114 -0
- tlgr/filters/message.py +161 -0
- tlgr/filters/temporal.py +87 -0
- tlgr/filters/user.py +36 -0
- tlgr/gateway/__init__.py +1 -0
- tlgr/gateway/config.py +161 -0
- tlgr/gateway/engine.py +215 -0
- tlgr/gateway/event.py +22 -0
- tlgr/jobs/__init__.py +0 -0
- tlgr/jobs/base.py +81 -0
- tlgr/jobs/client.py +37 -0
- tlgr/models/__init__.py +1220 -0
- tlgr/models/admin.py +744 -0
- tlgr/models/auth.py +510 -0
- tlgr/models/base.py +81 -0
- tlgr/models/bot.py +576 -0
- tlgr/models/business.py +265 -0
- tlgr/models/call.py +586 -0
- tlgr/models/config.py +101 -0
- tlgr/models/contact.py +481 -0
- tlgr/models/daemon.py +336 -0
- tlgr/models/dialog.py +626 -0
- tlgr/models/envelope.py +68 -0
- tlgr/models/error.py +30 -0
- tlgr/models/event.py +79 -0
- tlgr/models/export.py +66 -0
- tlgr/models/gift.py +275 -0
- tlgr/models/inline.py +84 -0
- tlgr/models/location.py +115 -0
- tlgr/models/media.py +507 -0
- tlgr/models/message.py +584 -0
- tlgr/models/net.py +232 -0
- tlgr/models/notify.py +105 -0
- tlgr/models/page.py +32 -0
- tlgr/models/payment.py +172 -0
- tlgr/models/peer.py +400 -0
- tlgr/models/poll.py +119 -0
- tlgr/models/premium.py +161 -0
- tlgr/models/privacy.py +93 -0
- tlgr/models/profile.py +217 -0
- tlgr/models/reaction.py +160 -0
- tlgr/models/resolve.py +175 -0
- tlgr/models/settings.py +103 -0
- tlgr/models/stars.py +101 -0
- tlgr/models/sticker.py +243 -0
- tlgr/models/story.py +467 -0
- tlgr/models/sync.py +105 -0
- tlgr/models/todo.py +36 -0
- tlgr/models/webapp.py +89 -0
- tlgr/ops/__init__.py +63 -0
- tlgr/ops/_admin.py +313 -0
- tlgr/ops/_auth.py +599 -0
- tlgr/ops/_bots.py +586 -0
- tlgr/ops/_calls.py +535 -0
- tlgr/ops/_common.py +160 -0
- tlgr/ops/_layer.py +46 -0
- tlgr/ops/_media.py +592 -0
- tlgr/ops/_params.py +212 -0
- tlgr/ops/_rights.py +402 -0
- tlgr/ops/_send.py +593 -0
- tlgr/ops/_serialize.py +667 -0
- tlgr/ops/_settings.py +306 -0
- tlgr/ops/_spec.py +167 -0
- tlgr/ops/_story.py +743 -0
- tlgr/ops/account.py +2604 -0
- tlgr/ops/agent.py +937 -0
- tlgr/ops/auth.py +1282 -0
- tlgr/ops/bot.py +4880 -0
- tlgr/ops/business.py +1520 -0
- tlgr/ops/call.py +1610 -0
- tlgr/ops/chat.py +4025 -0
- tlgr/ops/chat_admin.py +929 -0
- tlgr/ops/chat_extra.py +1061 -0
- tlgr/ops/chat_invite.py +716 -0
- tlgr/ops/chat_manage.py +1691 -0
- tlgr/ops/chat_member.py +1357 -0
- tlgr/ops/chat_stats.py +902 -0
- tlgr/ops/chat_topic.py +905 -0
- tlgr/ops/conference.py +791 -0
- tlgr/ops/config.py +1698 -0
- tlgr/ops/contact.py +2330 -0
- tlgr/ops/daemon.py +1397 -0
- tlgr/ops/draft.py +299 -0
- tlgr/ops/emoji.py +343 -0
- tlgr/ops/events.py +1327 -0
- tlgr/ops/export.py +596 -0
- tlgr/ops/folder.py +1322 -0
- tlgr/ops/gif.py +522 -0
- tlgr/ops/gift.py +1546 -0
- tlgr/ops/giveaway.py +541 -0
- tlgr/ops/inline.py +773 -0
- tlgr/ops/job.py +799 -0
- tlgr/ops/location.py +917 -0
- tlgr/ops/media.py +4495 -0
- tlgr/ops/message.py +3769 -0
- tlgr/ops/net.py +536 -0
- tlgr/ops/notify.py +840 -0
- tlgr/ops/passport.py +464 -0
- tlgr/ops/payment.py +907 -0
- tlgr/ops/poll.py +1078 -0
- tlgr/ops/premium.py +488 -0
- tlgr/ops/privacy.py +794 -0
- tlgr/ops/profile.py +1481 -0
- tlgr/ops/proxy.py +750 -0
- tlgr/ops/reaction.py +1475 -0
- tlgr/ops/resolve.py +1140 -0
- tlgr/ops/search.py +521 -0
- tlgr/ops/settings.py +1066 -0
- tlgr/ops/stars.py +594 -0
- tlgr/ops/sticker.py +1602 -0
- tlgr/ops/story.py +3216 -0
- tlgr/ops/sync.py +788 -0
- tlgr/ops/todo.py +514 -0
- tlgr/ops/user.py +1406 -0
- tlgr/ops/vc.py +2351 -0
- tlgr/ops/webapp.py +717 -0
- tlgr/ops/webhook.py +418 -0
- tlgr/parity.py +386 -0
- tlgr/processors/__init__.py +125 -0
- tlgr/processors/regex.py +26 -0
- tlgr/processors/text.py +56 -0
- tlgr/registry.py +519 -0
- tlgr/schema.py +173 -0
- tlgr/transport/__init__.py +30 -0
- tlgr/transport/autostart.py +293 -0
- tlgr/transport/client.py +805 -0
- tlgr/transport/ndjson.py +44 -0
- tlgr/version.py +31 -0
- tlgr_cli-2.0.1.dist-info/METADATA +957 -0
- tlgr_cli-2.0.1.dist-info/RECORD +192 -0
- tlgr_cli-2.0.1.dist-info/WHEEL +5 -0
- tlgr_cli-2.0.1.dist-info/entry_points.txt +2 -0
- tlgr_cli-2.0.1.dist-info/licenses/LICENSE +21 -0
- tlgr_cli-2.0.1.dist-info/top_level.txt +1 -0
tlgr/daemon/dispatch.py
ADDED
|
@@ -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()
|