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,805 @@
1
+ """The only code in the CLI that opens a socket (§5.1).
2
+
3
+ v1 wrote the request line by hand, concatenated headers into an f-string,
4
+ `json.dumps(..., default=str)`-ed the body, read until the socket went quiet
5
+ and then decoded chunked transfer-encoding with a hand-rolled loop over a
6
+ `str`. That produced COR-04 (a Persian search query never arrived), COR-31 (a
7
+ "timeout" was indistinguishable from a short read, so a truncated response
8
+ looked like success) and COR-32 (a chunked body with a multi-byte character on
9
+ a chunk boundary was corrupted).
10
+
11
+ All three are consequences of not using `http.client`, which already knows how
12
+ to frame a request, how to read a chunked body and how to raise on a truncated
13
+ one. This module supplies the two things it does not know: how to connect to a
14
+ Unix socket, and what tlgr's error envelope means.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import contextlib
20
+ import http.client
21
+ import os
22
+ import socket
23
+ import time
24
+ import uuid
25
+ from collections.abc import Iterator
26
+ from pathlib import Path
27
+ from typing import Any
28
+
29
+ import msgspec
30
+
31
+ from tlgr.core.errors import (
32
+ DaemonNotRunningError,
33
+ IPCError,
34
+ RetryableError,
35
+ TlgrError,
36
+ UsageError,
37
+ )
38
+ from tlgr.core.paths import TlgrPaths
39
+ from tlgr.models.envelope import OpRequest
40
+ from tlgr.transport import autostart
41
+ from tlgr.transport.ndjson import parse_frame
42
+ from tlgr.version import (
43
+ HEADER_CLIENT,
44
+ HEADER_PROTOCOL,
45
+ HEADER_REQUEST_ID,
46
+ HEADER_TOKEN,
47
+ PROTOCOL,
48
+ VERSION,
49
+ )
50
+
51
+ __all__ = [
52
+ "DaemonClient",
53
+ "admin",
54
+ "error_from_body",
55
+ "events",
56
+ "make_dispatcher",
57
+ "op",
58
+ "status",
59
+ "stream",
60
+ ]
61
+
62
+ DEFAULT_TIMEOUT = 120.0
63
+ #: A stream is open for as long as the caller wants it; the read timeout has
64
+ #: to be longer than the heartbeat interval or every quiet minute looks dead.
65
+ STREAM_TIMEOUT = 3600.0
66
+
67
+
68
+ class _UnixHTTPConnection(http.client.HTTPConnection):
69
+ """`http.client` over `AF_UNIX`. The whole connection story."""
70
+
71
+ def __init__(self, path: str, timeout: float) -> None:
72
+ super().__init__("localhost", timeout=timeout)
73
+ self._path = path
74
+
75
+ def connect(self) -> None:
76
+ sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
77
+ sock.settimeout(self.timeout)
78
+ try:
79
+ sock.connect(self._path)
80
+ except BaseException:
81
+ sock.close()
82
+ raise
83
+ self.sock = sock
84
+
85
+
86
+ #: Error codes that have a dedicated exception class. Everything else becomes
87
+ #: a `RemoteError` carrying the daemon's own code and exit status, so a code
88
+ #: added on the daemon side does not need a CLI release to exit correctly.
89
+ def _code_classes() -> dict[str, type[TlgrError]]:
90
+ from tlgr.core import errors
91
+
92
+ return {
93
+ "USAGE": errors.UsageError,
94
+ "ACCOUNT_REQUIRED": errors.AccountRequiredError,
95
+ "AUTH_ERROR": errors.AuthenticationError,
96
+ "AUTH_PASSWORD_REQUIRED": errors.AuthPasswordRequiredError,
97
+ "SESSION_ERROR": errors.SessionError,
98
+ "NOT_FOUND": errors.NotFoundError,
99
+ "CHAT_NOT_FOUND": errors.ChatNotFoundError,
100
+ "ACCOUNT_NOT_FOUND": errors.AccountNotFoundError,
101
+ "PERMISSION_DENIED": errors.PermissionError_,
102
+ "RATE_LIMITED": errors.RateLimitError,
103
+ "PEER_FLOOD": errors.SpamFlagError,
104
+ "ACCOUNT_FROZEN": errors.AccountFrozenError,
105
+ "RETRYABLE": errors.RetryableError,
106
+ "CONFIG_ERROR": errors.ConfigurationError,
107
+ "DAEMON_ERROR": errors.DaemonError,
108
+ "DAEMON_NOT_RUNNING": errors.DaemonNotRunningError,
109
+ "DAEMON_VERSION_MISMATCH": errors.DaemonVersionMismatchError,
110
+ "INDETERMINATE": errors.IndeterminateError,
111
+ "NOT_SUPPORTED": errors.NotSupportedError,
112
+ "IPC_ERROR": errors.IPCError,
113
+ }
114
+
115
+
116
+ class RemoteError(TlgrError):
117
+ """An error the daemon classified, carried across the socket intact.
118
+
119
+ The daemon has already decided the code, the exit status and whether a
120
+ retry is worth attempting; re-deriving any of that from an HTTP status on
121
+ this side would be a second opinion that can disagree with the first.
122
+ """
123
+
124
+
125
+ def error_from_body(body: dict[str, Any], *, status_code: int = 500) -> TlgrError:
126
+ """Rebuild the exception the daemon classified."""
127
+ message = str(body.get("message") or body.get("error") or f"daemon returned {status_code}")
128
+ code = str(body.get("code") or "IPC_ERROR")
129
+ classes = _code_classes()
130
+ klass = classes.get(code)
131
+
132
+ error: TlgrError
133
+ if code == "RATE_LIMITED" and klass is not None:
134
+ error = klass(message, wait_seconds=int(body.get("wait_seconds") or 0)) # type: ignore[call-arg]
135
+ elif klass is not None:
136
+ error = klass(message)
137
+ else:
138
+ error = RemoteError(message, code=code)
139
+
140
+ error.code = code
141
+ exit_code = body.get("exit_code")
142
+ if isinstance(exit_code, int):
143
+ error.exit_code = exit_code
144
+ if body.get("hint"):
145
+ error.hint = str(body["hint"])
146
+ if body.get("retryable"):
147
+ error.retryable = True
148
+ for extra in ("field", "wait_seconds", "rpc", "account", "request_id", "reason"):
149
+ if body.get(extra) is not None:
150
+ setattr(error, extra, body[extra])
151
+ return error
152
+
153
+
154
+ class DaemonClient:
155
+ """One connection story: connect, send JSON, read JSON or NDJSON.
156
+
157
+ Instances are cheap and hold no socket between calls — HTTP/1.1 with
158
+ `Connection: close` is the right trade for a CLI that makes one or two
159
+ requests and exits, and it removes a whole class of "the daemon restarted
160
+ under our keep-alive" failures.
161
+ """
162
+
163
+ def __init__(
164
+ self,
165
+ base: Path | None = None,
166
+ *,
167
+ timeout: float = DEFAULT_TIMEOUT,
168
+ auto_start: bool | None = None,
169
+ no_restart: bool = False,
170
+ token: str | None = None,
171
+ ) -> None:
172
+ self.paths = TlgrPaths(base)
173
+ self.timeout = timeout
174
+ self._auto_start = auto_start
175
+ self._no_restart = no_restart
176
+ self._token = token
177
+ self._ready = False
178
+ self._restarted = False
179
+
180
+ # -- configuration -----------------------------------------------------
181
+
182
+ @property
183
+ def auto_start(self) -> bool:
184
+ if self._auto_start is not None:
185
+ return self._auto_start
186
+ try:
187
+ from tlgr.core.config import load_app_config
188
+
189
+ return bool(load_app_config(self.paths.base).daemon.auto_start)
190
+ except Exception:
191
+ return True
192
+
193
+ @property
194
+ def start_timeout(self) -> float:
195
+ try:
196
+ from tlgr.core.config import load_app_config
197
+
198
+ return float(load_app_config(self.paths.base).daemon.start_timeout)
199
+ except Exception:
200
+ return 30.0
201
+
202
+ def token(self) -> str | None:
203
+ if self._token is not None:
204
+ return self._token
205
+ env = os.environ.get("TLGR_IPC_TOKEN", "").strip()
206
+ if env:
207
+ self._token = env
208
+ return env
209
+ try:
210
+ self._token = self.paths.token.read_text().strip() or None
211
+ except OSError:
212
+ self._token = None
213
+ return self._token
214
+
215
+ def _headers(self, request_id: str, *, body: bool) -> dict[str, str]:
216
+ headers = {
217
+ HEADER_CLIENT: f"tlgr/{VERSION}",
218
+ HEADER_PROTOCOL: str(PROTOCOL),
219
+ HEADER_REQUEST_ID: request_id,
220
+ "Accept": "application/json, application/x-ndjson",
221
+ "Connection": "close",
222
+ }
223
+ if body:
224
+ headers["Content-Type"] = "application/json"
225
+ token = self.token()
226
+ if token:
227
+ headers[HEADER_TOKEN] = token
228
+ return headers
229
+
230
+ # -- raw plumbing ------------------------------------------------------
231
+
232
+ def _open(
233
+ self,
234
+ method: str,
235
+ path: str,
236
+ *,
237
+ body: bytes | None = None,
238
+ timeout: float | None = None,
239
+ request_id: str = "",
240
+ ) -> tuple[_UnixHTTPConnection, http.client.HTTPResponse]:
241
+ conn = _UnixHTTPConnection(str(self.paths.socket), timeout or self.timeout)
242
+ try:
243
+ conn.request(
244
+ method, path, body=body, headers=self._headers(request_id, body=body is not None)
245
+ )
246
+ response = conn.getresponse()
247
+ except (FileNotFoundError, ConnectionRefusedError) as exc:
248
+ conn.close()
249
+ raise DaemonNotRunningError(f"cannot connect to the daemon socket: {exc}") from exc
250
+ except TimeoutError as exc:
251
+ conn.close()
252
+ raise RetryableError(
253
+ f"the daemon did not answer within {timeout or self.timeout:g}s"
254
+ ) from exc
255
+ except BaseException:
256
+ conn.close()
257
+ raise
258
+ return conn, response
259
+
260
+ def request(
261
+ self,
262
+ method: str,
263
+ path: str,
264
+ *,
265
+ body: Any = None,
266
+ params: dict[str, Any] | None = None,
267
+ timeout: float | None = None,
268
+ idempotent: bool | None = None,
269
+ request_id: str = "",
270
+ ) -> Any:
271
+ """One request, one decoded JSON body. Raises the daemon's error.
272
+
273
+ A connection that breaks *before* a reply is one retry for an
274
+ idempotent request (a GET, or an op that declares itself idempotent):
275
+ the daemon restarting between our connect and our write is common and
276
+ is not the caller's problem. A request that reached the daemon is
277
+ never retried — replaying a send is worse than reporting a failure.
278
+ """
279
+ if idempotent is None:
280
+ idempotent = method.upper() in ("GET", "HEAD")
281
+ target = _with_query(path, params)
282
+ payload = _encode_body(body)
283
+ request_id = request_id or _request_id()
284
+
285
+ attempts = 2 if idempotent else 1
286
+ last: Exception | None = None
287
+ for attempt in range(attempts):
288
+ self._ensure_daemon()
289
+ try:
290
+ conn, response = self._open(
291
+ method, target, body=payload, timeout=timeout, request_id=request_id
292
+ )
293
+ except (DaemonNotRunningError, ConnectionError, http.client.HTTPException) as exc:
294
+ last = exc
295
+ self._ready = False
296
+ if attempt + 1 < attempts:
297
+ continue
298
+ if isinstance(exc, TlgrError):
299
+ raise
300
+ raise IPCError(f"the daemon connection broke before it answered: {exc}") from exc
301
+ try:
302
+ raw = response.read()
303
+ except (http.client.IncompleteRead, ConnectionResetError, OSError) as exc:
304
+ conn.close()
305
+ last = exc
306
+ self._ready = False
307
+ if attempt + 1 < attempts:
308
+ continue
309
+ raise IPCError(f"the daemon closed the connection mid-reply: {exc}") from exc
310
+ finally:
311
+ conn.close()
312
+ return _decode(raw, response.status)
313
+ raise IPCError(str(last) if last else "request failed")
314
+
315
+ def stream(
316
+ self,
317
+ method: str,
318
+ path: str,
319
+ *,
320
+ body: Any = None,
321
+ params: dict[str, Any] | None = None,
322
+ timeout: float | None = None,
323
+ ) -> Iterator[dict[str, Any]]:
324
+ """Yield NDJSON frames until the daemon closes the response.
325
+
326
+ A stream that ends without an `end` frame is `RETRYABLE`, never a
327
+ silent success: "the connection dropped after 400 of 4,120 items" and
328
+ "there were 400 items" are different facts (§5.3).
329
+ """
330
+ self._ensure_daemon()
331
+ conn, response = self._open(
332
+ method,
333
+ _with_query(path, params),
334
+ body=_encode_body(body),
335
+ timeout=timeout if timeout is not None else STREAM_TIMEOUT,
336
+ request_id=_request_id(),
337
+ )
338
+ try:
339
+ if response.status >= 400:
340
+ yield from _error_frames(response.read(), response.status)
341
+ return
342
+ saw_end = False
343
+ while True:
344
+ line = response.readline()
345
+ if not line:
346
+ break
347
+ stripped = line.strip()
348
+ if not stripped:
349
+ continue
350
+ frame = parse_frame(stripped)
351
+ if frame.get("type") == "end":
352
+ saw_end = True
353
+ yield frame
354
+ if saw_end:
355
+ break
356
+ if not saw_end:
357
+ raise RetryableError(
358
+ "the daemon closed the stream without an end frame; "
359
+ "the result is incomplete — retry"
360
+ )
361
+ finally:
362
+ conn.close()
363
+
364
+ # -- protocol ----------------------------------------------------------
365
+
366
+ def probe_status(self) -> dict[str, Any] | None:
367
+ """`GET /v1/status`, or None when nothing is listening."""
368
+ try:
369
+ conn, response = self._open("GET", "/v1/status", timeout=5.0)
370
+ except (DaemonNotRunningError, RetryableError):
371
+ return None
372
+ try:
373
+ raw = response.read()
374
+ except OSError:
375
+ return None
376
+ finally:
377
+ conn.close()
378
+ if response.status != 200:
379
+ return None
380
+ try:
381
+ decoded = msgspec.json.decode(raw)
382
+ except msgspec.DecodeError:
383
+ return None
384
+ return decoded if isinstance(decoded, dict) else None
385
+
386
+ def _ensure_daemon(self) -> None:
387
+ """Start the daemon if needed and agree on a protocol, once per client."""
388
+ if self._ready:
389
+ return
390
+ state = autostart.ensure_running(
391
+ self.paths,
392
+ self.probe_status,
393
+ auto_start=self.auto_start,
394
+ start_timeout=self.start_timeout,
395
+ )
396
+ if autostart.check_protocol(state, client_protocol=PROTOCOL) == -1:
397
+ self._restart_older_daemon(state)
398
+ self._ready = True
399
+
400
+ def _restart_older_daemon(self, state: dict[str, Any]) -> None:
401
+ daemon = state.get("daemon") or {}
402
+ running = daemon.get("protocol", 0)
403
+ if self._no_restart:
404
+ from tlgr.core.errors import DaemonVersionMismatchError
405
+
406
+ raise DaemonVersionMismatchError(
407
+ f"the running daemon speaks protocol {running}, this CLI speaks {PROTOCOL}, "
408
+ "and --no-daemon-restart was given"
409
+ )
410
+ if self._restarted:
411
+ from tlgr.core.errors import DaemonVersionMismatchError
412
+
413
+ raise DaemonVersionMismatchError(
414
+ "restarted the daemon once and it still speaks an older protocol"
415
+ )
416
+ self._restarted = True
417
+ managed = autostart.daemon_state(self.paths)
418
+ if managed.supervised:
419
+ from tlgr.core.errors import DaemonVersionMismatchError
420
+
421
+ raise DaemonVersionMismatchError(
422
+ f"the daemon is managed by {managed.managed_by}; restart it with "
423
+ "tlgr daemon restart so the supervisor keeps ownership"
424
+ )
425
+
426
+ import sys
427
+
428
+ print(
429
+ f"daemon is running an older protocol ({running} < {PROTOCOL}); restarting it",
430
+ file=sys.stderr,
431
+ )
432
+ # A stop that fails is not fatal here: the point is only to make the
433
+ # old daemon let go, and the readiness poll below is the real check.
434
+ with contextlib.suppress(TlgrError):
435
+ self.request("POST", "/v1/admin/stop", body={"drain_s": 5}, timeout=10.0)
436
+ deadline = time.monotonic() + 15.0
437
+ while time.monotonic() < deadline and self.probe_status() is not None:
438
+ time.sleep(0.05)
439
+ fresh = autostart.ensure_running(
440
+ self.paths,
441
+ self.probe_status,
442
+ auto_start=True,
443
+ start_timeout=self.start_timeout,
444
+ )
445
+ autostart.check_protocol(fresh, client_protocol=PROTOCOL)
446
+
447
+ # -- the four verbs ----------------------------------------------------
448
+
449
+ def op(
450
+ self,
451
+ op_id: str,
452
+ request: Any = None,
453
+ *,
454
+ account: str = "",
455
+ dry_run: bool = False,
456
+ flood_wait_max: int | None = None,
457
+ limit: int | None = None,
458
+ cursor: str | None = None,
459
+ fetch_all: bool = False,
460
+ idempotent: bool = False,
461
+ timeout: float | None = None,
462
+ ) -> dict[str, Any]:
463
+ """`POST /v1/op` — the decoded success envelope, or the daemon's error."""
464
+ request_id = _request_id()
465
+ body = OpRequest(
466
+ op=op_id,
467
+ account=account,
468
+ request=_as_builtins(request),
469
+ dry_run=dry_run,
470
+ flood_wait_max=flood_wait_max,
471
+ request_id=request_id,
472
+ client_version=VERSION,
473
+ protocol=PROTOCOL,
474
+ limit=limit,
475
+ cursor=cursor,
476
+ all=fetch_all,
477
+ )
478
+ result = self.request(
479
+ "POST",
480
+ "/v1/op",
481
+ body=body,
482
+ timeout=timeout,
483
+ idempotent=idempotent,
484
+ request_id=request_id,
485
+ )
486
+ if not isinstance(result, dict):
487
+ raise IPCError("the daemon returned a non-object envelope")
488
+ return result
489
+
490
+ def op_stream(
491
+ self,
492
+ op_id: str,
493
+ request: Any = None,
494
+ *,
495
+ account: str = "",
496
+ dry_run: bool = False,
497
+ flood_wait_max: int | None = None,
498
+ limit: int | None = None,
499
+ cursor: str | None = None,
500
+ fetch_all: bool = False,
501
+ timeout: float | None = None,
502
+ ) -> Iterator[dict[str, Any]]:
503
+ body = OpRequest(
504
+ op=op_id,
505
+ account=account,
506
+ request=_as_builtins(request),
507
+ dry_run=dry_run,
508
+ flood_wait_max=flood_wait_max,
509
+ request_id=_request_id(),
510
+ client_version=VERSION,
511
+ protocol=PROTOCOL,
512
+ stream=True,
513
+ limit=limit,
514
+ cursor=cursor,
515
+ all=fetch_all,
516
+ )
517
+ return self.stream("POST", "/v1/op", body=body, timeout=timeout)
518
+
519
+ def events(
520
+ self,
521
+ *,
522
+ account: str,
523
+ types: str = "",
524
+ since: int | str | None = None,
525
+ chats: str = "",
526
+ timeout: int = 3600,
527
+ **extra: Any,
528
+ ) -> Iterator[dict[str, Any]]:
529
+ """`GET /v1/events` — the push stream, as NDJSON frames.
530
+
531
+ A GET-shaped alias of `POST /v1/op {op: events.watch}`: the daemon
532
+ decodes the query into the same request struct and runs the same
533
+ implementation, so there is one filter vocabulary rather than two.
534
+ """
535
+ params: dict[str, Any] = {"account": account, "timeout": timeout}
536
+ if types:
537
+ params["types"] = types
538
+ if since is not None:
539
+ params["since"] = since
540
+ if chats:
541
+ params["chats"] = chats
542
+ params.update({k: v for k, v in extra.items() if v is not None})
543
+ return self.stream("GET", "/v1/events", params=params, timeout=timeout + 30)
544
+
545
+ def status(self) -> dict[str, Any]:
546
+ result = self.request("GET", "/v1/status", timeout=15.0)
547
+ if not isinstance(result, dict):
548
+ raise IPCError("the daemon returned a non-object status")
549
+ return result
550
+
551
+ def admin(self, action: str, body: dict[str, Any] | None = None) -> dict[str, Any]:
552
+ if "/" in action:
553
+ raise UsageError(f"invalid admin action {action!r}")
554
+ result = self.request("POST", f"/v1/admin/{action}", body=body or {})
555
+ if not isinstance(result, dict):
556
+ raise IPCError("the daemon returned a non-object admin reply")
557
+ return result
558
+
559
+
560
+ # ---------------------------------------------------------------------------
561
+ # Helpers
562
+ # ---------------------------------------------------------------------------
563
+
564
+
565
+ def _request_id() -> str:
566
+ return uuid.uuid4().hex
567
+
568
+
569
+ def _encode_body(body: Any) -> bytes | None:
570
+ if body is None:
571
+ return None
572
+ if isinstance(body, bytes):
573
+ return body
574
+ return msgspec.json.encode(body)
575
+
576
+
577
+ def _as_builtins(request: Any) -> dict[str, Any]:
578
+ if request is None:
579
+ return {}
580
+ if isinstance(request, dict):
581
+ return request
582
+ converted = msgspec.to_builtins(request)
583
+ return converted if isinstance(converted, dict) else {}
584
+
585
+
586
+ def _with_query(path: str, params: dict[str, Any] | None) -> str:
587
+ """Append *params*, always through `urlencode`.
588
+
589
+ This is COR-04's fix on the query side: `f"?chat={chat}"` with `chat` set
590
+ to `سلام #12` produced a path the server split at the `#` and decoded as
591
+ Latin-1. `urlencode` is the only spelling allowed in this file.
592
+ """
593
+ if not params:
594
+ return path
595
+ from urllib.parse import urlencode
596
+
597
+ pairs = [(k, v) for k, v in params.items() if v is not None]
598
+ if not pairs:
599
+ return path
600
+ query = urlencode([(k, _stringify(v)) for k, v in pairs])
601
+ separator = "&" if "?" in path else "?"
602
+ return f"{path}{separator}{query}"
603
+
604
+
605
+ def _stringify(value: Any) -> str:
606
+ if isinstance(value, bool):
607
+ return "true" if value else "false"
608
+ return str(value)
609
+
610
+
611
+ def _query_value(value: Any) -> str:
612
+ """One request field as one query value.
613
+
614
+ A repeated `--chat` is a list of parsed `PeerRef`s; the query carries the
615
+ references the user typed, comma-separated, and the daemon parses them
616
+ with the same parser the CLI used. Sending the parsed dicts would mean two
617
+ peer parsers that could disagree about what `-100…` means.
618
+ """
619
+ if isinstance(value, dict) and "raw" in value:
620
+ return str(value["raw"])
621
+ if isinstance(value, (list, tuple)):
622
+ return ",".join(_query_value(item) for item in value)
623
+ return _stringify(value)
624
+
625
+
626
+ def _decode(raw: bytes, status_code: int) -> Any:
627
+ try:
628
+ decoded = msgspec.json.decode(raw) if raw else None
629
+ except msgspec.DecodeError as exc:
630
+ if status_code >= 400:
631
+ raise IPCError(f"daemon error ({status_code}): {raw[:200]!r}") from exc
632
+ raise IPCError(f"the daemon returned a body that is not JSON: {raw[:200]!r}") from exc
633
+
634
+ if status_code < 400:
635
+ return decoded
636
+
637
+ body: dict[str, Any] = {}
638
+ if isinstance(decoded, dict):
639
+ inner = decoded.get("error")
640
+ body = inner if isinstance(inner, dict) else decoded
641
+ raise error_from_body(body, status_code=status_code)
642
+
643
+
644
+ def _error_frames(raw: bytes, status_code: int) -> Iterator[dict[str, Any]]:
645
+ try:
646
+ _decode(raw, status_code)
647
+ except TlgrError as exc:
648
+ from tlgr.core.errors import classify, error_body_dict
649
+
650
+ yield {"type": "end", "ok": False, "error": error_body_dict(classify(exc))}
651
+ return
652
+ yield {"type": "end", "ok": False, "error": {"code": "IPC_ERROR", "message": "stream failed"}}
653
+
654
+
655
+ # ---------------------------------------------------------------------------
656
+ # Module-level convenience — one shared client per process
657
+ # ---------------------------------------------------------------------------
658
+
659
+ _default: DaemonClient | None = None
660
+
661
+
662
+ def default_client(base: Path | None = None) -> DaemonClient:
663
+ global _default
664
+ if _default is None or (base is not None and _default.paths.base != Path(base)):
665
+ _default = DaemonClient(base)
666
+ return _default
667
+
668
+
669
+ def reset_default_client() -> None:
670
+ """Drop the shared client. Tests that move `TLGR_HOME` need this."""
671
+ global _default
672
+ _default = None
673
+
674
+
675
+ def op(op_id: str, request: Any = None, **kwargs: Any) -> dict[str, Any]:
676
+ return default_client().op(op_id, request, **kwargs)
677
+
678
+
679
+ def stream(op_id: str, request: Any = None, **kwargs: Any) -> Iterator[dict[str, Any]]:
680
+ return default_client().op_stream(op_id, request, **kwargs)
681
+
682
+
683
+ def events(**kwargs: Any) -> Iterator[dict[str, Any]]:
684
+ return default_client().events(**kwargs)
685
+
686
+
687
+ def status() -> dict[str, Any]:
688
+ return default_client().status()
689
+
690
+
691
+ def admin(action: str, body: dict[str, Any] | None = None) -> dict[str, Any]:
692
+ return default_client().admin(action, body)
693
+
694
+
695
+ #: Set once by the CLI root from `--flood-wait-max`. Legacy commands do not
696
+ #: thread the flag through their own bodies (there are forty of them), and
697
+ #: dropping it silently is COR-15 — the flag existed and did nothing.
698
+ # ---------------------------------------------------------------------------
699
+ # The CLI dispatcher
700
+ # ---------------------------------------------------------------------------
701
+
702
+
703
+ def make_dispatcher(base: Path | None = None) -> Any:
704
+ """Build the callable `cli.gen.set_dispatcher()` wants.
705
+
706
+ `run_op` has already resolved the account, enforced `--enable-commands`
707
+ by canonical id and short-circuited `--dry-run`; all that is left is the
708
+ round trip. Keeping it that thin is what makes the CLI testable without a
709
+ daemon and the daemon testable without a CLI.
710
+ """
711
+
712
+ def dispatch(spec: Any, request: Any, state: Any) -> dict[str, Any]:
713
+ client = DaemonClient(
714
+ base,
715
+ timeout=float(state.timeout) if state.timeout else float(spec.timeout_s),
716
+ no_restart=bool(state.no_daemon_restart),
717
+ )
718
+ common: dict[str, Any] = {
719
+ "account": state.account or "",
720
+ "dry_run": bool(state.dry_run),
721
+ "flood_wait_max": state.flood_wait_max,
722
+ "limit": getattr(state, "limit", None),
723
+ "cursor": getattr(state, "cursor", None),
724
+ "fetch_all": bool(getattr(state, "fetch_all", False)),
725
+ }
726
+ if spec.stream or common["fetch_all"]:
727
+ return _collect(client.op_stream(spec.id, request, **common), spec.id)
728
+ return client.op(spec.id, request, idempotent=spec.idempotent, **common)
729
+
730
+ return dispatch
731
+
732
+
733
+ def make_stream_dispatcher(base: Path | None = None) -> Any:
734
+ """The dispatcher a live-stream command uses: frames, as they arrive.
735
+
736
+ `make_dispatcher` folds a walk back into one envelope, which is right for
737
+ `--all` and wrong for `watch`: a stream that only prints when it ends is
738
+ not a stream. This one yields, and the CLI writes each frame.
739
+
740
+ `events.watch` goes over `GET /v1/events` rather than `POST /v1/op`
741
+ because that is the documented endpoint for the push stream and it is
742
+ reachable with `curl`; the daemon serves both from the same operation.
743
+ """
744
+
745
+ def dispatch(spec: Any, request: Any, state: Any) -> Iterator[dict[str, Any]]:
746
+ client = DaemonClient(
747
+ base,
748
+ timeout=float(state.timeout) if state.timeout else float(spec.timeout_s),
749
+ no_restart=bool(state.no_daemon_restart),
750
+ )
751
+ body = _as_builtins(request)
752
+ if spec.id == "events.watch":
753
+ follow_for = int(body.pop("follow_for", 3600) or 3600)
754
+ return client.events(
755
+ account=state.account or "",
756
+ timeout=follow_for,
757
+ **{
758
+ key: _query_value(value)
759
+ for key, value in body.items()
760
+ if value is not None and value != []
761
+ },
762
+ )
763
+ return client.op_stream(
764
+ spec.id,
765
+ request,
766
+ account=state.account or "",
767
+ dry_run=bool(state.dry_run),
768
+ flood_wait_max=state.flood_wait_max,
769
+ limit=getattr(state, "limit", None),
770
+ cursor=getattr(state, "cursor", None),
771
+ fetch_all=bool(getattr(state, "fetch_all", False)),
772
+ )
773
+
774
+ return dispatch
775
+
776
+
777
+ def _collect(frames: Iterator[dict[str, Any]], op_id: str) -> dict[str, Any]:
778
+ """Fold an NDJSON walk back into one envelope for the renderer."""
779
+ items: list[Any] = []
780
+ meta: dict[str, Any] = {}
781
+ page: dict[str, Any] | None = None
782
+ account: str | None = None
783
+ for frame in frames:
784
+ kind = frame.get("type")
785
+ if kind == "meta":
786
+ account = frame.get("account") or None
787
+ meta["request_id"] = frame.get("request_id", "")
788
+ elif kind == "item":
789
+ items.append(frame.get("data"))
790
+ elif kind == "page":
791
+ page = {
792
+ "has_more": bool(frame.get("has_more")),
793
+ "next_cursor": frame.get("next_cursor"),
794
+ }
795
+ elif kind == "end":
796
+ if not frame.get("ok", True):
797
+ raise error_from_body(frame.get("error") or {})
798
+ meta["elapsed_ms"] = frame.get("elapsed_ms", 0)
799
+ meta["count"] = frame.get("count", len(items))
800
+ envelope: dict[str, Any] = {"ok": True, "op": op_id, "result": items, "meta": meta}
801
+ if account:
802
+ envelope["account"] = account
803
+ if page is not None:
804
+ envelope["page"] = page
805
+ return envelope