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,30 @@
1
+ """The client half of the v2 wire protocol (§5).
2
+
3
+ `cli/` reaches the daemon only through this package: one connection story,
4
+ one place timeouts and retries are decided, one place an error envelope
5
+ becomes an exception.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from tlgr.transport.client import (
11
+ DaemonClient,
12
+ admin,
13
+ events,
14
+ make_dispatcher,
15
+ make_stream_dispatcher,
16
+ op,
17
+ status,
18
+ stream,
19
+ )
20
+
21
+ __all__ = [
22
+ "DaemonClient",
23
+ "admin",
24
+ "events",
25
+ "make_dispatcher",
26
+ "make_stream_dispatcher",
27
+ "op",
28
+ "status",
29
+ "stream",
30
+ ]
@@ -0,0 +1,293 @@
1
+ """Starting the daemon exactly once, whoever asks first (§5.8, COR-14).
2
+
3
+ v1's version of this file is the reason two `tlgr` commands in a shell
4
+ pipeline could produce two daemons, and the reason a daemon owned by another
5
+ uid was reported as "not running" and then had its pid file deleted. Four
6
+ rules, each of which was broken:
7
+
8
+ 1. **The probe is a lock, not a file test.** `daemon.pid` existing says
9
+ nothing; the process may be gone, or may belong to someone else. Spawning
10
+ is serialised by an exclusive `flock`, so twenty simultaneous CLIs produce
11
+ one daemon.
12
+ 2. **Readiness is an HTTP 200 from `/v1/status`,** not the socket file
13
+ appearing. The socket exists from `bind()`, which is before any account is
14
+ connected and before the middleware is installed.
15
+ 3. **Nothing unlinks another process's socket or pid file.** A stale socket is
16
+ removed only while holding the spawn lock and only once the pid it belongs
17
+ to is shown to be gone.
18
+ 4. **`PermissionError` is not "not running".** `os.kill(pid, 0)` raising
19
+ `EPERM` means the process exists and belongs to somebody else — the one
20
+ case where deleting its files is worst.
21
+
22
+ The spawn lock is a *separate* file from the daemon's own singleton lock. They
23
+ cannot be the same file: the parent holds the spawn lock across the spawn, and
24
+ the child takes the singleton lock as its first act, so sharing one file would
25
+ deadlock every start.
26
+ """
27
+
28
+ from __future__ import annotations
29
+
30
+ import contextlib
31
+ import errno
32
+ import fcntl
33
+ import json
34
+ import os
35
+ import socket
36
+ import subprocess
37
+ import sys
38
+ import time
39
+ from collections.abc import Callable
40
+ from dataclasses import dataclass
41
+ from pathlib import Path
42
+ from typing import Any
43
+
44
+ from tlgr.core.errors import DaemonError, DaemonNotRunningError, DaemonVersionMismatchError
45
+ from tlgr.core.paths import TlgrPaths
46
+
47
+ __all__ = [
48
+ "SPAWN_LOCK",
49
+ "DaemonState",
50
+ "daemon_state",
51
+ "is_listening",
52
+ "read_pid",
53
+ "spawn_daemon",
54
+ "wait_ready",
55
+ ]
56
+
57
+ SPAWN_LOCK = "daemon.spawn.lock"
58
+
59
+ _POLL_MIN = 0.02
60
+ _POLL_MAX = 0.25
61
+
62
+
63
+ @dataclass(frozen=True)
64
+ class DaemonState:
65
+ """What `daemon.state` says, when it says anything."""
66
+
67
+ version: str = ""
68
+ protocol: int = 0
69
+ pid: int = 0
70
+ socket: str = ""
71
+ managed_by: str = ""
72
+ started_at: str = ""
73
+
74
+ @property
75
+ def supervised(self) -> bool:
76
+ """True when launchd/systemd owns the process, so we must not fork one."""
77
+ return self.managed_by in ("launchd", "systemd")
78
+
79
+
80
+ def daemon_state(paths: TlgrPaths) -> DaemonState:
81
+ try:
82
+ raw = json.loads(paths.state.read_text())
83
+ except (OSError, json.JSONDecodeError, ValueError):
84
+ return DaemonState()
85
+ if not isinstance(raw, dict):
86
+ return DaemonState()
87
+ return DaemonState(
88
+ version=str(raw.get("version", "")),
89
+ protocol=int(raw.get("protocol", 0) or 0),
90
+ pid=int(raw.get("pid", 0) or 0),
91
+ socket=str(raw.get("socket", "")),
92
+ managed_by=str(raw.get("managed_by", "")),
93
+ started_at=str(raw.get("started_at", "")),
94
+ )
95
+
96
+
97
+ def read_pid(paths: TlgrPaths) -> int | None:
98
+ """The daemon's pid if a live process holds it, else None.
99
+
100
+ A `PermissionError` from `kill(pid, 0)` means the pid is *taken* by a
101
+ process we may not signal. Reporting that as "not running" (v1) led
102
+ straight to deleting a running daemon's pid file.
103
+ """
104
+ try:
105
+ pid = int(paths.pid.read_text().strip())
106
+ except (OSError, ValueError):
107
+ return None
108
+ if pid <= 0:
109
+ return None
110
+ try:
111
+ os.kill(pid, 0)
112
+ except ProcessLookupError:
113
+ return None
114
+ except PermissionError:
115
+ return pid
116
+ return pid
117
+
118
+
119
+ def is_listening(socket_path: Path, timeout: float = 0.5) -> bool:
120
+ """Can we open the socket at all? Distinguishes 'gone' from 'refusing'."""
121
+ sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
122
+ sock.settimeout(timeout)
123
+ try:
124
+ sock.connect(str(socket_path))
125
+ return True
126
+ except (FileNotFoundError, ConnectionRefusedError):
127
+ return False
128
+ except PermissionError:
129
+ # Someone else's daemon, or a socket we may not open. Either way it is
130
+ # running: refusing loudly beats replacing it.
131
+ return True
132
+ except OSError as exc:
133
+ # ENOTSOCK is a leftover *file* at the socket path — a crash during
134
+ # bind, or a copied home directory. Anything else unexpected is
135
+ # treated as "running": refusing loudly beats replacing a live daemon.
136
+ return exc.errno not in (errno.ECONNREFUSED, errno.ENOENT, errno.ENOTSOCK)
137
+ finally:
138
+ sock.close()
139
+
140
+
141
+ def _remove_stale_socket(paths: TlgrPaths) -> None:
142
+ """Remove a socket whose owner is provably gone. Only under the spawn lock."""
143
+ if not paths.socket.exists():
144
+ return
145
+ if is_listening(paths.socket):
146
+ return
147
+ if read_pid(paths) is not None:
148
+ # A live process owns the pid file: its socket is not ours to remove,
149
+ # even when it is not answering yet.
150
+ return
151
+ with contextlib.suppress(OSError):
152
+ paths.socket.unlink()
153
+
154
+
155
+ def spawn_daemon(base: Path, *, python: str | None = None) -> subprocess.Popen[bytes]:
156
+ """Fork the daemon into its own session, detached from this terminal."""
157
+ return subprocess.Popen(
158
+ [python or sys.executable, "-m", "tlgr.daemon.main", "--base", str(base)],
159
+ stdout=subprocess.DEVNULL,
160
+ stderr=subprocess.DEVNULL,
161
+ stdin=subprocess.DEVNULL,
162
+ start_new_session=True,
163
+ )
164
+
165
+
166
+ #: `() -> status dict | None`. A probe returns None while nothing answers.
167
+ StatusProbe = Callable[[], "dict[str, Any] | None"]
168
+
169
+
170
+ def wait_ready(
171
+ probe: StatusProbe,
172
+ *,
173
+ timeout: float,
174
+ ) -> dict[str, Any] | None:
175
+ """Poll *probe* (a zero-argument callable returning the status dict or None).
176
+
177
+ Backs off from 20 ms to 250 ms so a fast start costs one poll and a slow
178
+ one does not spin a core.
179
+ """
180
+ end = time.monotonic() + timeout
181
+ wait = _POLL_MIN
182
+ while True:
183
+ result = probe()
184
+ if result is not None:
185
+ return result
186
+ if time.monotonic() >= end:
187
+ return None
188
+ time.sleep(min(wait, max(0.0, end - time.monotonic())))
189
+ wait = min(wait * 1.5, _POLL_MAX)
190
+
191
+
192
+ def log_tail(paths: TlgrPaths, lines: int = 20) -> str:
193
+ """The last few log lines, for a start-up failure message."""
194
+ try:
195
+ content = paths.log_file.read_text(errors="replace").splitlines()
196
+ except OSError:
197
+ return ""
198
+ return "\n".join(content[-lines:])
199
+
200
+
201
+ class SpawnLock:
202
+ """`flock` around the decision to spawn. Context manager, non-blocking."""
203
+
204
+ def __init__(self, base: Path) -> None:
205
+ self.path = base / SPAWN_LOCK
206
+ self._fd: int | None = None
207
+ self.acquired = False
208
+
209
+ def __enter__(self) -> SpawnLock:
210
+ self.path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
211
+ self._fd = os.open(str(self.path), os.O_CREAT | os.O_RDWR, 0o600)
212
+ try:
213
+ fcntl.flock(self._fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
214
+ self.acquired = True
215
+ except OSError:
216
+ self.acquired = False
217
+ return self
218
+
219
+ def __exit__(self, *exc: object) -> None:
220
+ if self._fd is not None:
221
+ if self.acquired:
222
+ with contextlib.suppress(OSError): # pragma: no cover
223
+ fcntl.flock(self._fd, fcntl.LOCK_UN)
224
+ os.close(self._fd)
225
+ self._fd = None
226
+
227
+
228
+ def ensure_running(
229
+ paths: TlgrPaths,
230
+ status_probe: StatusProbe,
231
+ *,
232
+ auto_start: bool,
233
+ start_timeout: float,
234
+ python: str | None = None,
235
+ ) -> dict[str, Any]:
236
+ """Return the daemon's `/v1/status`, starting it if that is allowed.
237
+
238
+ `status_probe()` returns the decoded status dict or `None` when the daemon
239
+ is not answering. Everything here is about *not* starting a second one.
240
+ """
241
+ current = status_probe()
242
+ if current is not None:
243
+ return current
244
+
245
+ state = daemon_state(paths)
246
+ if state.supervised:
247
+ raise DaemonNotRunningError(
248
+ f"the daemon is managed by {state.managed_by} and is not answering; "
249
+ f"start it with: tlgr daemon restart"
250
+ )
251
+
252
+ if not auto_start:
253
+ raise DaemonNotRunningError(
254
+ "Daemon is not running and [daemon] auto_start is false. "
255
+ "Start it with: tlgr daemon start"
256
+ )
257
+
258
+ with SpawnLock(paths.base) as lock:
259
+ # Whoever lost the race polls; whoever won double-checks before
260
+ # spawning, because the winner may have been queued behind a start
261
+ # that has just finished.
262
+ current = status_probe()
263
+ if current is not None:
264
+ return current
265
+ if lock.acquired:
266
+ _remove_stale_socket(paths)
267
+ spawn_daemon(paths.base, python=python)
268
+ ready = wait_ready(status_probe, timeout=start_timeout)
269
+
270
+ if ready is None:
271
+ tail = log_tail(paths)
272
+ detail = f"\nLast lines of {paths.log_file}:\n{tail}" if tail else ""
273
+ raise DaemonError(f"daemon did not become ready within {start_timeout:g}s{detail}")
274
+ return ready
275
+
276
+
277
+ def check_protocol(status: dict[str, Any], *, client_protocol: int) -> int:
278
+ """Compare protocols and say what to do (§5.7).
279
+
280
+ Returns 0 to proceed, -1 when the daemon is older and must be restarted,
281
+ and raises when it is newer — killing a newer daemon to satisfy an older
282
+ CLI would break whatever started it.
283
+ """
284
+ daemon = status.get("daemon") or {}
285
+ protocol = int(daemon.get("protocol", 0) or 0)
286
+ if protocol == client_protocol:
287
+ return 0
288
+ if protocol > client_protocol:
289
+ raise DaemonVersionMismatchError(
290
+ f"the running daemon speaks protocol {protocol}, this CLI speaks "
291
+ f"{client_protocol}; upgrade the CLI, or run: tlgr daemon stop"
292
+ )
293
+ return -1