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,274 @@
1
+ """alias → `AccountSession`, with a lock per alias (COR-12).
2
+
3
+ v1's `ensure_client()` was `if alias not in self._clients: await connect()`.
4
+ Two concurrent requests for an account that was not yet connected both saw the
5
+ miss, both constructed a `TelegramClient` on the same session file, and the
6
+ second one's connect invalidated the first's auth key — `AUTH_KEY_DUPLICATED`,
7
+ which Telegram treats as a compromised session and revokes.
8
+
9
+ The fix is boring and complete: one `asyncio.Lock` per alias, a **double
10
+ check** inside it (the whole point — the first waiter has already done the
11
+ work by the time the second gets in), and a `flock` on the session file so
12
+ that even a second *process* cannot do what the second coroutine could not.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import asyncio
18
+ import contextlib
19
+ import logging
20
+ from typing import Any
21
+
22
+ from tlgr.core.accounts import AccountManager
23
+ from tlgr.core.config import AppConfig
24
+ from tlgr.core.errors import AccountNotFoundError, ConfigurationError
25
+ from tlgr.core.identity import load_identity
26
+ from tlgr.core.paths import TlgrPaths, validate_alias
27
+ from tlgr.daemon.ratelimit import RateLimiter
28
+ from tlgr.daemon.session import AccountSession, ClientOptions, SessionState, build_client
29
+
30
+ log = logging.getLogger("tlgr.daemon.sessions")
31
+
32
+ __all__ = ["SessionManager"]
33
+
34
+
35
+ class SessionManager:
36
+ def __init__(
37
+ self,
38
+ paths: TlgrPaths,
39
+ config: AppConfig,
40
+ *,
41
+ accounts: AccountManager | None = None,
42
+ client_factory: Any = build_client,
43
+ on_session_ready: Any = None,
44
+ ) -> None:
45
+ self.paths = paths
46
+ self.config = config
47
+ self.accounts = accounts or AccountManager(paths.base)
48
+ self._factory = client_factory
49
+ self._on_ready = on_session_ready
50
+ self._sessions: dict[str, AccountSession] = {}
51
+ self._limiters: dict[str, RateLimiter] = {}
52
+ self._locks: dict[str, asyncio.Lock] = {}
53
+
54
+ # -- accessors ---------------------------------------------------------
55
+
56
+ def __contains__(self, alias: str) -> bool:
57
+ return alias in self._sessions
58
+
59
+ def get(self, alias: str) -> AccountSession | None:
60
+ return self._sessions.get(alias)
61
+
62
+ @property
63
+ def aliases(self) -> list[str]:
64
+ return list(self._sessions)
65
+
66
+ def limiter(self, alias: str) -> RateLimiter:
67
+ """The account's rate limiter, created on first use.
68
+
69
+ Created lazily rather than with the session because a flood deadline
70
+ outlives a connection: an account that failed to connect still owes the
71
+ wait it earned before it dropped.
72
+ """
73
+ if alias not in self._limiters:
74
+ validate_alias(alias)
75
+ buckets = {
76
+ name: (bucket.rate, bucket.burst) for name, bucket in self.config.rate.items()
77
+ }
78
+ self._limiters[alias] = RateLimiter(
79
+ buckets=buckets,
80
+ flood_path=self.paths.flood(alias),
81
+ persist=self.config.flood.persist,
82
+ sleep_threshold=self.config.flood.sleep_threshold,
83
+ max_wait=self.config.flood.max_wait,
84
+ )
85
+ return self._limiters[alias]
86
+
87
+ def _lock(self, alias: str) -> asyncio.Lock:
88
+ if alias not in self._locks:
89
+ self._locks[alias] = asyncio.Lock()
90
+ return self._locks[alias]
91
+
92
+ # -- construction ------------------------------------------------------
93
+
94
+ def _options(self, alias: str) -> ClientOptions:
95
+ api_id, api_hash = self.accounts.load_credentials(alias)
96
+ if not api_id or not api_hash:
97
+ raise ConfigurationError(
98
+ f"account {alias!r} has no API credentials. Run: tlgr account add --alias {alias}"
99
+ )
100
+ identity = load_identity(
101
+ self.paths.base,
102
+ device_model=self.config.identity.device_model,
103
+ system_version=self.config.identity.system_version,
104
+ lang_code=self.config.identity.lang_code,
105
+ system_lang_code=self.config.identity.system_lang_code,
106
+ )
107
+ return ClientOptions(
108
+ api_id=int(api_id),
109
+ api_hash=str(api_hash),
110
+ entity_cache_limit=self.config.limits.entity_cache,
111
+ request_retries=self.config.limits.request_retries,
112
+ connect_timeout=self.config.network.connect_timeout,
113
+ flood_sleep_threshold=self.config.flood.sleep_threshold,
114
+ device_model=identity.device_model,
115
+ system_version=identity.system_version,
116
+ app_version=identity.app_version,
117
+ lang_code=identity.lang_code,
118
+ system_lang_code=identity.system_lang_code,
119
+ proxy=_proxy_tuple(self.config.network.proxy),
120
+ use_ipv6=self.config.network.ipv6,
121
+ params=identity.params() if self.config.identity.tz_offset else {},
122
+ )
123
+
124
+ def _make(self, alias: str) -> AccountSession:
125
+ def on_state(state: str, reason: str, user_id: int | None) -> None:
126
+ with contextlib.suppress(Exception):
127
+ self.accounts.set_health(alias, state, reason=reason, user_id=user_id)
128
+
129
+ return AccountSession(
130
+ alias,
131
+ session_path=self.paths.session(alias),
132
+ lock_path=self.paths.session_lock(alias),
133
+ options=self._options(alias),
134
+ client_factory=self._factory,
135
+ on_state=on_state,
136
+ state_save_interval=self.config.daemon.state_save_interval,
137
+ presence=self.config.presence.mode,
138
+ resync_depth=self.config.daemon.resync_depth,
139
+ peers_path=self.paths.peers_db(alias),
140
+ dialog_scan_max=self.config.limits.dialog_scan_max,
141
+ )
142
+
143
+ # -- the one entry point -----------------------------------------------
144
+
145
+ async def ensure(self, alias: str) -> AccountSession:
146
+ """Return the account's session, connecting it on demand — exactly once."""
147
+ validate_alias(alias)
148
+ existing = self._sessions.get(alias)
149
+ if existing is not None:
150
+ return existing
151
+
152
+ async with self._lock(alias):
153
+ # The double check is the fix: by the time a second caller reaches
154
+ # here, the first has already built and started the session.
155
+ existing = self._sessions.get(alias)
156
+ if existing is not None:
157
+ return existing
158
+ if not self.accounts.get_account(alias) and not self.paths.account_dir(alias).exists():
159
+ raise AccountNotFoundError(f"no account named {alias!r}. Run: tlgr account list")
160
+ session = self._make(alias)
161
+ await session.start()
162
+ self._sessions[alias] = session
163
+ if self._on_ready is not None:
164
+ with contextlib.suppress(Exception):
165
+ await self._on_ready(session)
166
+ return session
167
+
168
+ async def connect_all(self, aliases: list[str]) -> dict[str, str]:
169
+ """Connect the start-up list concurrently; failures do not block readiness.
170
+
171
+ The list is ordered (§6.1) and the results are reported per alias, so
172
+ one account with a revoked session cannot stop the others from working
173
+ — which is what "connect them in a loop and raise" did in v1.
174
+ """
175
+ results: dict[str, str] = {}
176
+
177
+ async def connect(alias: str) -> None:
178
+ try:
179
+ session = await self.ensure(alias)
180
+ results[alias] = session.state
181
+ except Exception as exc:
182
+ results[alias] = f"error: {exc}"
183
+ log.warning(
184
+ "could not connect account %s: %s", alias, exc, extra={"account": alias}
185
+ )
186
+
187
+ await asyncio.gather(*(connect(alias) for alias in aliases))
188
+ return results
189
+
190
+ # -- shutdown ----------------------------------------------------------
191
+
192
+ async def stop_all(self, *, timeout: float = 10.0) -> None:
193
+ await asyncio.gather(
194
+ *(session.stop(timeout=timeout) for session in list(self._sessions.values())),
195
+ return_exceptions=True,
196
+ )
197
+ self._sessions.clear()
198
+ for limiter in self._limiters.values():
199
+ limiter.flood.save()
200
+
201
+ async def release(self, alias: str) -> None:
202
+ session = self._sessions.pop(alias, None)
203
+ if session is not None:
204
+ await session.stop()
205
+
206
+ # -- reporting ---------------------------------------------------------
207
+
208
+ def snapshot(self) -> list[dict[str, Any]]:
209
+ """One row per account, in the order they were connected."""
210
+ rows: list[dict[str, Any]] = []
211
+ for alias, session in self._sessions.items():
212
+ row = session.snapshot()
213
+ row.update(self.limiter(alias).snapshot())
214
+ rows.append(row)
215
+ for info in self.accounts.list_accounts():
216
+ if info.alias in self._sessions:
217
+ continue
218
+ health = info.health
219
+ rows.append(
220
+ {
221
+ "alias": info.alias,
222
+ "state": health.state if health.state != "unknown" else "not_connected",
223
+ "reason": health.reason or None,
224
+ "since": health.since or None,
225
+ "user_id": info.user_id,
226
+ }
227
+ )
228
+ return rows
229
+
230
+ @property
231
+ def in_flight(self) -> int:
232
+ return sum(session.in_flight for session in self._sessions.values())
233
+
234
+ @property
235
+ def online(self) -> list[str]:
236
+ return [
237
+ alias
238
+ for alias, session in self._sessions.items()
239
+ if session.state == SessionState.ONLINE
240
+ ]
241
+
242
+
243
+ def _proxy_tuple(spec: str) -> Any:
244
+ """`socks5://user:pass@host:1080` → the tuple Telethon wants, or None.
245
+
246
+ Returned as a dict for `python-socks`-style proxies because the tuple form
247
+ silently ignores authentication on some Telethon versions.
248
+ """
249
+ if not spec:
250
+ return None
251
+ from urllib.parse import urlparse
252
+
253
+ parsed = urlparse(spec)
254
+ scheme = (parsed.scheme or "").lower()
255
+ if not parsed.hostname or not parsed.port:
256
+ raise ConfigurationError(f"[network] proxy is not a URL with a host and port: {spec!r}")
257
+ if scheme in ("socks5", "socks4", "http"):
258
+ proxy: dict[str, Any] = {
259
+ "proxy_type": scheme,
260
+ "addr": parsed.hostname,
261
+ "port": int(parsed.port),
262
+ "rdns": True,
263
+ }
264
+ if parsed.username:
265
+ proxy["username"] = parsed.username
266
+ if parsed.password:
267
+ proxy["password"] = parsed.password
268
+ return proxy
269
+ if scheme == "mtproxy":
270
+ secret = (parsed.fragment or "").strip()
271
+ if not secret:
272
+ raise ConfigurationError("an mtproxy:// proxy needs its secret after a '#'")
273
+ return (parsed.hostname, int(parsed.port), secret)
274
+ raise ConfigurationError(f"unsupported proxy scheme {scheme!r}")
@@ -0,0 +1,114 @@
1
+ """`flock`-based single ownership, for the daemon and for each session file.
2
+
3
+ Two different things are guarded and they fail differently:
4
+
5
+ * **the daemon** — one process per `~/.tlgr`. A second one exits *0* with a
6
+ message rather than 1, because under launchd a non-zero exit with
7
+ `KeepAlive.SuccessfulExit=false` means "respawn me", and v1's exit 1 on
8
+ "already running" produced an infinite respawn loop (COR-39).
9
+ * **a session file** — one process per `accounts/<alias>/session.session`.
10
+ Two Telethon clients on one session file is how you earn
11
+ `AUTH_KEY_DUPLICATED`, which invalidates the session server-side; the
12
+ daemon holds this lock for the life of the account and the CLI never opens
13
+ a session file at all.
14
+
15
+ `flock` is per open file description, so the lock is released by closing the
16
+ fd and, crucially, is *not* inherited across a `Popen`. That is what lets the
17
+ autostart probe hold a lock while spawning a child that takes a different one.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import contextlib
23
+ import fcntl
24
+ import os
25
+ from pathlib import Path
26
+ from types import TracebackType
27
+
28
+ __all__ = ["FileLock", "LockBusy"]
29
+
30
+
31
+ class LockBusy(Exception):
32
+ """Somebody else holds the lock. Carries their pid when we can read it."""
33
+
34
+ def __init__(self, path: Path, pid: int | None = None) -> None:
35
+ self.path = path
36
+ self.pid = pid
37
+ holder = f" (held by pid {pid})" if pid else ""
38
+ super().__init__(f"{path} is locked by another process{holder}")
39
+
40
+
41
+ class FileLock:
42
+ """An exclusive advisory lock on a file, held until `release()`.
43
+
44
+ The holder writes its pid into the file so that a refusal can name it —
45
+ "the lock is busy" is not an actionable message, "pid 8123 holds it" is.
46
+ """
47
+
48
+ def __init__(self, path: Path, *, mode: int = 0o600) -> None:
49
+ self.path = Path(path)
50
+ self.mode = mode
51
+ self._fd: int | None = None
52
+
53
+ @property
54
+ def held(self) -> bool:
55
+ return self._fd is not None
56
+
57
+ def holder_pid(self) -> int | None:
58
+ try:
59
+ content = self.path.read_text().strip()
60
+ except OSError:
61
+ return None
62
+ try:
63
+ return int(content)
64
+ except ValueError:
65
+ return None
66
+
67
+ def acquire(self, *, blocking: bool = False) -> None:
68
+ """Take the lock, raising `LockBusy` when someone else has it."""
69
+ self.path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
70
+ fd = os.open(str(self.path), os.O_CREAT | os.O_RDWR, self.mode)
71
+ flags = fcntl.LOCK_EX if blocking else fcntl.LOCK_EX | fcntl.LOCK_NB
72
+ try:
73
+ fcntl.flock(fd, flags)
74
+ except OSError as exc:
75
+ pid = self.holder_pid()
76
+ os.close(fd)
77
+ raise LockBusy(self.path, pid) from exc
78
+ self._fd = fd
79
+ self.write_pid()
80
+
81
+ def write_pid(self) -> None:
82
+ """(Re)write our pid into the locked file.
83
+
84
+ Called again after `daemonize()`: the pid written before the fork
85
+ belongs to a process that no longer exists, and a stale pid in a lock
86
+ file is worse than no pid at all.
87
+ """
88
+ if self._fd is None:
89
+ return
90
+ os.ftruncate(self._fd, 0)
91
+ os.lseek(self._fd, 0, os.SEEK_SET)
92
+ os.write(self._fd, f"{os.getpid()}\n".encode())
93
+ os.fsync(self._fd)
94
+
95
+ def release(self) -> None:
96
+ if self._fd is None:
97
+ return
98
+ with contextlib.suppress(OSError):
99
+ fcntl.flock(self._fd, fcntl.LOCK_UN)
100
+ with contextlib.suppress(OSError):
101
+ os.close(self._fd)
102
+ self._fd = None
103
+
104
+ def __enter__(self) -> FileLock:
105
+ self.acquire()
106
+ return self
107
+
108
+ def __exit__(
109
+ self,
110
+ exc_type: type[BaseException] | None,
111
+ exc: BaseException | None,
112
+ tb: TracebackType | None,
113
+ ) -> None:
114
+ self.release()
tlgr/daemon/stream.py ADDED
@@ -0,0 +1,193 @@
1
+ """NDJSON responses: `--all` walks, progress, and the event stream (§5.3/§5.4).
2
+
3
+ Two rules make a stream trustworthy and both were missing in v1:
4
+
5
+ * **exactly one `meta` first and exactly one `end` last.** A stream that ends
6
+ without an `end` frame is a failure, not a short result — the client raises
7
+ `RETRYABLE` rather than reporting "there were 400 items" when there were
8
+ 4,120 and the connection dropped.
9
+ * **the walk happens here, inside the daemon.** v1's `--all` was a client
10
+ loop that re-issued a request per page as fast as the socket allowed, with
11
+ no account pacing between pages (ROB-01). Walking server-side means the
12
+ account's own rate limiter sits between pages, and a 10k-dialog enumeration
13
+ is one request with backpressure instead of a hundred without.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import asyncio
19
+ import contextlib
20
+ import logging
21
+ import time
22
+ from collections.abc import AsyncIterator
23
+ from typing import Any
24
+
25
+ from aiohttp import web
26
+
27
+ from tlgr.core.errors import classify, error_body_dict
28
+ from tlgr.models.base import to_builtins
29
+ from tlgr.transport.ndjson import dump_frame
30
+ from tlgr.version import PROTOCOL
31
+
32
+ log = logging.getLogger("tlgr.daemon.stream")
33
+
34
+ __all__ = ["NdjsonResponse", "walk_pages"]
35
+
36
+ #: §13.5 caps: a walk that has produced this much is stopped with a warning
37
+ #: rather than running until the client gives up.
38
+ MAX_WALK_ITEMS = 100_000
39
+ MAX_WALK_SECONDS = 3600
40
+
41
+
42
+ class NdjsonResponse:
43
+ """A chunked `application/x-ndjson` response with the framing rules baked in."""
44
+
45
+ def __init__(self, request: web.Request) -> None:
46
+ self._request = request
47
+ self._response: web.StreamResponse | None = None
48
+ self._ended = False
49
+
50
+ async def prepare(self, **meta: Any) -> None:
51
+ response = web.StreamResponse(
52
+ status=200,
53
+ headers={
54
+ "Content-Type": "application/x-ndjson",
55
+ "Cache-Control": "no-store",
56
+ },
57
+ )
58
+ response.enable_chunked_encoding()
59
+ await response.prepare(self._request)
60
+ self._response = response
61
+ await self.write({"type": "meta", "protocol": PROTOCOL, **meta})
62
+
63
+ async def write(self, frame: dict[str, Any]) -> None:
64
+ if self._response is None: # pragma: no cover - prepare() comes first
65
+ raise RuntimeError("the NDJSON response was not prepared")
66
+ await self._response.write(dump_frame(frame))
67
+
68
+ async def end(self, **fields: Any) -> web.StreamResponse:
69
+ """Write the single `end` frame and close. Idempotent."""
70
+ if self._response is None: # pragma: no cover
71
+ raise RuntimeError("the NDJSON response was not prepared")
72
+ if not self._ended:
73
+ self._ended = True
74
+ await self.write({"type": "end", **fields})
75
+ with contextlib.suppress(Exception):
76
+ await self._response.write_eof()
77
+ return self._response
78
+
79
+ async def fail(self, exc: BaseException, *, account: str = "") -> web.StreamResponse:
80
+ body = error_body_dict(classify(exc, account=account or None))
81
+ return await self.end(ok=False, error=body)
82
+
83
+ @property
84
+ def started(self) -> bool:
85
+ return self._response is not None
86
+
87
+
88
+ async def walk_pages(
89
+ pages: AsyncIterator[Any],
90
+ stream: NdjsonResponse,
91
+ *,
92
+ limiter: Any = None,
93
+ rate_class: str = "read",
94
+ max_items: int = MAX_WALK_ITEMS,
95
+ max_seconds: int = MAX_WALK_SECONDS,
96
+ ) -> int:
97
+ """Stream every item from an async page iterator, paced by the limiter.
98
+
99
+ Returns the number of items written. The caps are not a policy about what
100
+ a user may ask for; they are the difference between a walk that ends and
101
+ one that quietly becomes a permanent background load on the account.
102
+ """
103
+ started = time.monotonic()
104
+ count = 0
105
+ async for page in pages:
106
+ # `_attr` checks the dict case first: `getattr({}, "items")` is the
107
+ # dict *method*, which iterates into a very confusing TypeError.
108
+ items = _attr(page, "items")
109
+ for item in items or ():
110
+ count += 1
111
+ await stream.write({"type": "item", "seq": count, "data": to_builtins(item)})
112
+ if count >= max_items:
113
+ await stream.write(
114
+ {
115
+ "type": "page",
116
+ "has_more": True,
117
+ "next_cursor": _cursor_of(page),
118
+ "fetched": count,
119
+ "truncated": f"stopped at the {max_items} item cap",
120
+ }
121
+ )
122
+ return count
123
+ await stream.write(
124
+ {
125
+ "type": "page",
126
+ "has_more": bool(_attr(page, "has_more")),
127
+ "next_cursor": _cursor_of(page),
128
+ "fetched": count,
129
+ "elapsed_ms": int((time.monotonic() - started) * 1000),
130
+ }
131
+ )
132
+ if not _attr(page, "has_more"):
133
+ break
134
+ if time.monotonic() - started > max_seconds:
135
+ await stream.write(
136
+ {"type": "page", "has_more": True, "truncated": "stopped at the time cap"}
137
+ )
138
+ break
139
+ if limiter is not None:
140
+ # Backpressure lives here: the next page waits for the account's
141
+ # own bucket, so a walk paces itself instead of racing the server.
142
+ await limiter.acquire(rate_class)
143
+ return count
144
+
145
+
146
+ def _attr(page: Any, name: str) -> Any:
147
+ if isinstance(page, dict):
148
+ return page.get(name)
149
+ return getattr(page, name, None)
150
+
151
+
152
+ def _cursor_of(page: Any) -> Any:
153
+ return _attr(page, "next_cursor")
154
+
155
+
156
+ async def pump_events(
157
+ stream: NdjsonResponse,
158
+ subscriber: Any,
159
+ *,
160
+ heartbeat: float = 15.0,
161
+ timeout: float = 3600.0,
162
+ shutdown: asyncio.Event | None = None,
163
+ ) -> str:
164
+ """Deliver events until the timeout, the client leaves, or we shut down.
165
+
166
+ Returns the reason, which the caller puts in the `end` frame — "the
167
+ stream ended" without saying why is exactly the ambiguity that makes a
168
+ consumer guess whether to reconnect.
169
+ """
170
+ deadline = time.monotonic() + timeout
171
+ while True:
172
+ if shutdown is not None and shutdown.is_set():
173
+ return "shutdown"
174
+ remaining = deadline - time.monotonic()
175
+ if remaining <= 0:
176
+ return "timeout"
177
+ try:
178
+ event = await asyncio.wait_for(
179
+ subscriber.queue.get(), timeout=min(heartbeat, remaining)
180
+ )
181
+ except (TimeoutError, asyncio.TimeoutError):
182
+ await stream.write({"type": "heartbeat", "ts": _now()})
183
+ continue
184
+ lag = subscriber.take_lag()
185
+ if lag:
186
+ await stream.write({"type": "lag", "dropped": lag})
187
+ await stream.write(to_builtins(event))
188
+
189
+
190
+ def _now() -> str:
191
+ from datetime import datetime, timezone
192
+
193
+ return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")