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
tlgr/daemon/session.py ADDED
@@ -0,0 +1,648 @@
1
+ """One account, one client, one supervisor (§6.2, §6.3).
2
+
3
+ v1 constructed a `TelegramClient` with `connection_retries=5` and treated the
4
+ resulting object as permanently usable. When the route to Telegram dropped for
5
+ longer than five retries, Telethon raised, left the client in the daemon's
6
+ dict, and every subsequent request failed with `ConnectionError: Cannot send
7
+ requests while disconnected` — while `tlgr daemon status` reported the account
8
+ as present and healthy. That is COR-13 and it is the reason this file exists.
9
+
10
+ The rules it enforces:
11
+
12
+ * **the supervisor owns backoff**, not Telethon (`connection_retries=None`),
13
+ so a drop is a state transition rather than an exception thrown at whoever
14
+ happened to be making a request;
15
+ * **`catch_up()` runs after every reconnect**, not only at construction.
16
+ Telethon's own reconnect handler calls `get_me()` and nothing else, so an
17
+ account that was down for ten minutes silently misses everything that
18
+ happened. It also runs after a wall-clock jump, which is what a laptop lid
19
+ looks like from inside the process;
20
+ * **update state is persisted every minute**, not only on a clean
21
+ `disconnect()`, so a SIGKILL costs at most a minute of `pts` progress;
22
+ * **a fatal auth error is terminal.** `AUTH_KEY_UNREGISTERED` will not fix
23
+ itself; reconnecting forever while reporting "degraded" (v1) hides a
24
+ revoked session behind a transient-looking state;
25
+ * **the state is written where the CLI can read it**, so `tlgr account list`
26
+ is honest even with the daemon down.
27
+ """
28
+
29
+ from __future__ import annotations
30
+
31
+ import asyncio
32
+ import contextlib
33
+ import logging
34
+ import random
35
+ import time
36
+ from collections.abc import Awaitable, Callable
37
+ from dataclasses import dataclass, field
38
+ from pathlib import Path
39
+ from typing import Any
40
+
41
+ from tlgr.core import telethon_compat as compat
42
+ from tlgr.core.errors import (
43
+ ConfigurationError,
44
+ RetryableError,
45
+ SessionError,
46
+ is_fatal_auth,
47
+ )
48
+ from tlgr.daemon.singleton import FileLock, LockBusy
49
+
50
+ log = logging.getLogger("tlgr.daemon.session")
51
+
52
+ __all__ = ["AccountSession", "SessionState", "backoff_delays"]
53
+
54
+ #: 1, 2, 4, 8, 16, 32, 60, 60… with ±20 % jitter, capped at a minute (§6.3).
55
+ _BACKOFF_CAP = 60.0
56
+ _JITTER = 0.2
57
+
58
+ #: How far a wall-clock/monotonic divergence has to go before it counts as a
59
+ #: sleep rather than as scheduling noise.
60
+ _CLOCK_JUMP_SECONDS = 60.0
61
+ _CLOCK_TICK_SECONDS = 30.0
62
+
63
+ #: The belt-and-braces resync interval for an account that has heard nothing.
64
+ _SILENCE_CATCHUP_SECONDS = 900.0
65
+
66
+
67
+ class SessionState:
68
+ STARTING = "starting"
69
+ ONLINE = "online"
70
+ DEGRADED = "degraded"
71
+ NEEDS_LOGIN = "needs_login"
72
+ FROZEN = "frozen"
73
+ STOPPING = "stopping"
74
+ STOPPED = "stopped"
75
+
76
+
77
+ def backoff_delays(attempt: int, *, cap: float = _BACKOFF_CAP) -> float:
78
+ """The delay before reconnect attempt *attempt* (0-based), with jitter.
79
+
80
+ Jitter matters with more than one account: without it, four accounts that
81
+ dropped together reconnect together, and the reconnection storm is itself
82
+ a reason to be rate limited.
83
+ """
84
+ base = min(cap, 2.0**attempt)
85
+ return base * (1.0 + random.uniform(-_JITTER, _JITTER))
86
+
87
+
88
+ @dataclass
89
+ class ClientOptions:
90
+ """Everything `TelegramClient(...)` needs that is not the session path."""
91
+
92
+ api_id: int
93
+ api_hash: str
94
+ entity_cache_limit: int = 20000
95
+ request_retries: int = 5
96
+ connect_timeout: int = 10
97
+ flood_sleep_threshold: int = 120
98
+ device_model: str = ""
99
+ system_version: str = ""
100
+ app_version: str = ""
101
+ lang_code: str = "en"
102
+ system_lang_code: str = "en"
103
+ proxy: Any = None
104
+ use_ipv6: bool = False
105
+ connection: Any = None
106
+ params: dict[str, int] = field(default_factory=dict)
107
+
108
+
109
+ def build_client(session_path: Path, options: ClientOptions) -> Any:
110
+ """Construct the Telethon client tlgr wants (§6.2).
111
+
112
+ `connection_retries=None` is the load-bearing argument: it means "retry
113
+ forever inside Telethon" is *off*, and the supervisor below decides what a
114
+ disconnection means.
115
+ """
116
+ from telethon import TelegramClient
117
+
118
+ kwargs: dict[str, Any] = {
119
+ "catch_up": True,
120
+ "sequential_updates": True,
121
+ "raise_last_call_error": True,
122
+ "entity_cache_limit": int(options.entity_cache_limit),
123
+ "connection_retries": None,
124
+ "retry_delay": 1,
125
+ "request_retries": int(options.request_retries),
126
+ "auto_reconnect": True,
127
+ "timeout": int(options.connect_timeout),
128
+ "flood_sleep_threshold": int(options.flood_sleep_threshold),
129
+ "device_model": options.device_model or None,
130
+ "system_version": options.system_version or None,
131
+ "app_version": options.app_version or None,
132
+ "lang_code": options.lang_code or "en",
133
+ "system_lang_code": options.system_lang_code or "en",
134
+ "use_ipv6": bool(options.use_ipv6),
135
+ }
136
+ if options.proxy:
137
+ kwargs["proxy"] = options.proxy
138
+ if options.connection is not None:
139
+ kwargs["connection"] = options.connection
140
+ return TelegramClient(str(session_path), options.api_id, options.api_hash, **kwargs)
141
+
142
+
143
+ class AccountSession:
144
+ """The state machine of §6.2, plus the supervisor of §6.3."""
145
+
146
+ def __init__(
147
+ self,
148
+ alias: str,
149
+ *,
150
+ session_path: Path,
151
+ lock_path: Path,
152
+ options: ClientOptions,
153
+ client_factory: Callable[[Path, ClientOptions], Any] = build_client,
154
+ on_state: Callable[[str, str, int | None], None] | None = None,
155
+ on_event: Callable[[str, Any], None] | None = None,
156
+ state_save_interval: int = 60,
157
+ presence: str = "off",
158
+ resync_depth: int = 50,
159
+ peers_path: Path | None = None,
160
+ dialog_scan_max: int = 5000,
161
+ ) -> None:
162
+ self.alias = alias
163
+ self.session_path = session_path
164
+ self.lock = FileLock(lock_path)
165
+ self.options = options
166
+ self._factory = client_factory
167
+ self._on_state = on_state
168
+ self._on_event = on_event
169
+ self.state_save_interval = state_save_interval
170
+ self.presence = presence
171
+ self.resync_depth = resync_depth
172
+ self.peers_path = peers_path
173
+ self.dialog_scan_max = dialog_scan_max
174
+ self._resolver: Any = None
175
+
176
+ self.client: Any = None
177
+ self.me: Any = None
178
+ self.state: str = SessionState.STOPPED
179
+ self.reason: str = ""
180
+ self.since: float = time.time()
181
+ self.connected_since: float | None = None
182
+ self.last_update: float = 0.0
183
+ self.reconnects: int = 0
184
+ self.in_flight: int = 0
185
+ self.resync_needed: list[int] = []
186
+ self.catch_up_pending: bool = False
187
+ self._attempt = 0
188
+ self._stopping = False
189
+ self._ready = asyncio.Event()
190
+ self._supervisor: asyncio.Task[None] | None = None
191
+ self._tickers: list[asyncio.Task[None]] = []
192
+ self._job_client: Any = None
193
+ self._flood_budgets: list[int] = []
194
+
195
+ # -- state -------------------------------------------------------------
196
+
197
+ def _set_state(self, state: str, reason: str = "") -> None:
198
+ if state == self.state and reason == self.reason:
199
+ return
200
+ self.state = state
201
+ self.reason = reason
202
+ self.since = time.time()
203
+ if state == SessionState.ONLINE:
204
+ self._ready.set()
205
+ else:
206
+ self._ready.clear()
207
+ log.info(
208
+ "account %s is %s%s",
209
+ self.alias,
210
+ state,
211
+ f" ({reason})" if reason else "",
212
+ extra={"account": self.alias, "state": state, "reason": reason},
213
+ )
214
+ if self._on_state is not None:
215
+ user_id = getattr(self.me, "id", None)
216
+ with contextlib.suppress(Exception):
217
+ self._on_state(state, reason, user_id)
218
+
219
+ @property
220
+ def healthy(self) -> bool:
221
+ return self.state == SessionState.ONLINE
222
+
223
+ @property
224
+ def connected(self) -> bool:
225
+ return bool(self.client is not None and self.client.is_connected())
226
+
227
+ # -- lifecycle ---------------------------------------------------------
228
+
229
+ async def start(self) -> None:
230
+ """Take the session lock and run the supervisor in the background."""
231
+ if self._supervisor is not None:
232
+ return
233
+ try:
234
+ self.lock.acquire()
235
+ except LockBusy as exc:
236
+ self._set_state(SessionState.STOPPED, "session file is owned by another process")
237
+ raise ConfigurationError(
238
+ f"account {self.alias!r}: {exc}. Only one process may hold a session file; "
239
+ "stop the other daemon (tlgr daemon stop) and try again."
240
+ ) from exc
241
+ self._stopping = False
242
+ self._set_state(SessionState.STARTING)
243
+ self._supervisor = asyncio.create_task(self._supervise(), name=f"tlgr-session-{self.alias}")
244
+
245
+ async def stop(self, *, timeout: float = 10.0) -> None:
246
+ """Shut down cleanly: presence, state save, disconnect, unlock (§6.11)."""
247
+ self._stopping = True
248
+ self._set_state(SessionState.STOPPING)
249
+ for task in self._tickers:
250
+ task.cancel()
251
+ self._tickers = []
252
+ if self._supervisor is not None:
253
+ self._supervisor.cancel()
254
+ with contextlib.suppress(asyncio.CancelledError, Exception):
255
+ await self._supervisor
256
+ self._supervisor = None
257
+ if self.client is not None:
258
+ with contextlib.suppress(Exception):
259
+ await asyncio.wait_for(self._graceful_client_shutdown(), timeout)
260
+ self.client = None
261
+ self.lock.release()
262
+ self._set_state(SessionState.STOPPED)
263
+
264
+ async def _graceful_client_shutdown(self) -> None:
265
+ if self.presence == "online":
266
+ await self._set_presence(offline=True)
267
+ await compat.save_state(self.client)
268
+ await self.client.disconnect()
269
+
270
+ # -- the supervisor ----------------------------------------------------
271
+
272
+ async def _supervise(self) -> None:
273
+ while not self._stopping:
274
+ try:
275
+ await self._connect_once()
276
+ except asyncio.CancelledError:
277
+ raise
278
+ except Exception as exc:
279
+ if is_fatal_auth(exc):
280
+ self._set_state(SessionState.NEEDS_LOGIN, type(exc).__name__)
281
+ return
282
+ self._set_state(SessionState.DEGRADED, f"{type(exc).__name__}: {exc}")
283
+ delay = backoff_delays(self._attempt)
284
+ self._attempt += 1
285
+ log.warning(
286
+ "account %s reconnecting in %.1fs (attempt %d)",
287
+ self.alias,
288
+ delay,
289
+ self._attempt,
290
+ extra={"account": self.alias, "attempt": self._attempt},
291
+ )
292
+ await asyncio.sleep(delay)
293
+ continue
294
+
295
+ if self.state == SessionState.NEEDS_LOGIN:
296
+ return
297
+ # `_connect_once` returned because the connection dropped.
298
+ if self._stopping:
299
+ return
300
+ self.reconnects += 1
301
+ self._set_state(SessionState.DEGRADED, "connection lost")
302
+ delay = backoff_delays(self._attempt)
303
+ self._attempt += 1
304
+ await asyncio.sleep(delay)
305
+
306
+ async def _connect_once(self) -> None:
307
+ """Connect, authorise, catch up, then wait for the connection to end."""
308
+ if self.client is None:
309
+ self.client = self._factory(self.session_path, self.options)
310
+ self._install_hooks()
311
+
312
+ await self.client.connect()
313
+ if not await self.client.is_user_authorized():
314
+ self._set_state(SessionState.NEEDS_LOGIN, "not authorized")
315
+ raise SessionError(f"account {self.alias!r} is not logged in")
316
+
317
+ self.me = await self.client.get_me()
318
+ self._attempt = 0
319
+ self.connected_since = time.time()
320
+ await self._after_connect()
321
+ self._set_state(SessionState.ONLINE)
322
+ self._start_tickers()
323
+
324
+ disconnected = getattr(self.client, "disconnected", None)
325
+ if disconnected is None:
326
+ return
327
+ await disconnected
328
+
329
+ async def _after_connect(self) -> None:
330
+ """Warm the entity cache, then catch up (§6.3).
331
+
332
+ The warm-up is not an optimisation: `catch_up()` skips channels whose
333
+ `pts` it has never seen, so without one `iter_dialogs` pass a fresh
334
+ session silently gets no channel updates at all.
335
+ """
336
+ await self._warm_entity_cache()
337
+ await self.catch_up()
338
+ if self.presence == "online":
339
+ await self._set_presence(offline=False)
340
+
341
+ async def _warm_entity_cache(self) -> None:
342
+ iterator = getattr(self.client, "iter_dialogs", None)
343
+ if iterator is None:
344
+ return
345
+ count = 0
346
+ try:
347
+ async for _dialog in iterator(limit=None):
348
+ count += 1
349
+ except asyncio.CancelledError:
350
+ raise
351
+ except Exception as exc:
352
+ log.debug("entity cache warm-up failed for %s: %s", self.alias, exc)
353
+ else:
354
+ log.debug("warmed %d dialogs for %s", count, self.alias)
355
+
356
+ async def catch_up(self) -> None:
357
+ catch_up = getattr(self.client, "catch_up", None)
358
+ if catch_up is None:
359
+ return
360
+ self.catch_up_pending = True
361
+ try:
362
+ await catch_up()
363
+ except asyncio.CancelledError:
364
+ raise
365
+ except Exception as exc:
366
+ log.warning(
367
+ "catch_up failed for %s: %s", self.alias, exc, extra={"account": self.alias}
368
+ )
369
+ finally:
370
+ self.catch_up_pending = False
371
+
372
+ # -- hooks -------------------------------------------------------------
373
+
374
+ def _install_hooks(self) -> None:
375
+ compat.install_reconnect_hook(self.client, self._on_reconnect)
376
+ compat.install_too_long_hook(self.client, self._on_too_long)
377
+
378
+ async def _on_reconnect(self) -> None:
379
+ """Telethon reconnected under us; the state it holds is stale."""
380
+ self.reconnects += 1
381
+ await self.catch_up()
382
+
383
+ def _on_too_long(self, scope: str, channel_id: int | None) -> None:
384
+ """The server said our gap is unrecoverable (checklist 9)."""
385
+ if scope == compat.TOO_LONG_CHANNEL and channel_id is not None:
386
+ if channel_id not in self.resync_needed:
387
+ self.resync_needed.append(int(channel_id))
388
+ else:
389
+ if 0 not in self.resync_needed:
390
+ self.resync_needed.append(0)
391
+ log.warning(
392
+ "difference too long for %s (%s); a resync is needed",
393
+ self.alias,
394
+ channel_id if channel_id is not None else "account",
395
+ extra={"account": self.alias},
396
+ )
397
+
398
+ # -- periodic work -----------------------------------------------------
399
+
400
+ def _start_tickers(self) -> None:
401
+ if self._tickers:
402
+ return
403
+ self._tickers = [
404
+ asyncio.create_task(self._state_saver(), name=f"tlgr-state-{self.alias}"),
405
+ asyncio.create_task(self._clock_watcher(), name=f"tlgr-clock-{self.alias}"),
406
+ ]
407
+
408
+ async def _state_saver(self) -> None:
409
+ while not self._stopping:
410
+ await asyncio.sleep(max(5, self.state_save_interval))
411
+ if self.client is not None and self.connected:
412
+ await compat.save_state(self.client)
413
+
414
+ async def _clock_watcher(self) -> None:
415
+ """Catch up after a wall-clock jump, which is what sleep looks like.
416
+
417
+ Comparing `time.time()` against `time.monotonic()` is the only portable
418
+ way to notice that the machine was suspended: no timer fires while it
419
+ is, so the daemon wakes believing thirty seconds passed.
420
+ """
421
+ wall = time.time()
422
+ mono = time.monotonic()
423
+ quiet_since = time.monotonic()
424
+ while not self._stopping:
425
+ await asyncio.sleep(_CLOCK_TICK_SECONDS)
426
+ new_wall, new_mono = time.time(), time.monotonic()
427
+ drift = (new_wall - wall) - (new_mono - mono)
428
+ wall, mono = new_wall, new_mono
429
+ if abs(drift) >= _CLOCK_JUMP_SECONDS and self.connected:
430
+ log.info(
431
+ "wall clock jumped %.0fs on %s; catching up",
432
+ drift,
433
+ self.alias,
434
+ extra={"account": self.alias},
435
+ )
436
+ await self.catch_up()
437
+ quiet_since = time.monotonic()
438
+ continue
439
+ if (
440
+ self.connected
441
+ and self.last_update
442
+ and time.monotonic() - quiet_since >= _SILENCE_CATCHUP_SECONDS
443
+ ):
444
+ await self.catch_up()
445
+ quiet_since = time.monotonic()
446
+
447
+ async def _set_presence(self, *, offline: bool) -> None:
448
+ """`account.updateStatus`. Telethon never sends it, so tlgr reads as offline."""
449
+ try:
450
+ from telethon.tl.functions.account import UpdateStatusRequest
451
+
452
+ await self.client(UpdateStatusRequest(offline=offline))
453
+ except asyncio.CancelledError:
454
+ raise
455
+ except Exception as exc:
456
+ log.debug("presence update failed for %s: %s", self.alias, exc)
457
+
458
+ @property
459
+ def resolver(self) -> Any:
460
+ """The account's entity resolver, built once the client exists.
461
+
462
+ One per account, never shared: an access hash minted for one account
463
+ is meaningless to another, and handing it over produces
464
+ `PEER_ID_INVALID` for a peer that plainly exists (§6.6).
465
+ """
466
+ from tlgr.core.peers import PeerCache, PeerResolver
467
+
468
+ if self._resolver is None or self._resolver.client is not self.client:
469
+ self._resolver = PeerResolver(
470
+ client=self.client,
471
+ account=self.alias,
472
+ cache=PeerCache(self.peers_path),
473
+ dialog_scan_max=self.dialog_scan_max,
474
+ )
475
+ return self._resolver
476
+
477
+ # -- request gate ------------------------------------------------------
478
+
479
+ async def acquire(self, *, timeout: float) -> Any:
480
+ """Return a usable client, or raise the error the state table demands.
481
+
482
+ This is the one place `Cannot send requests while disconnected` is
483
+ prevented from reaching a user: a degraded account waits briefly for a
484
+ reconnect and then answers `RETRYABLE` with a hint, and a revoked one
485
+ answers `SESSION_ERROR` immediately.
486
+ """
487
+ if self.state == SessionState.NEEDS_LOGIN:
488
+ raise SessionError(
489
+ f"account {self.alias!r} needs to log in again ({self.reason or 'session invalid'})"
490
+ )
491
+ if self.state in (SessionState.STOPPING, SessionState.STOPPED):
492
+ raise RetryableError(f"account {self.alias!r} is not running")
493
+ if self.state == SessionState.ONLINE and self.connected:
494
+ return self.client
495
+
496
+ wait = min(timeout, 15.0) if self.state == SessionState.DEGRADED else min(timeout, 10.0)
497
+ try:
498
+ await asyncio.wait_for(self._ready.wait(), wait)
499
+ except (TimeoutError, asyncio.TimeoutError) as exc:
500
+ if self.state == SessionState.NEEDS_LOGIN:
501
+ raise SessionError(
502
+ f"account {self.alias!r} needs to log in again ({self.reason})"
503
+ ) from exc
504
+ raise RetryableError(
505
+ f"account {self.alias!r} is {self.state}"
506
+ + (f" ({self.reason})" if self.reason else "")
507
+ + " — the daemon is reconnecting; retry in a few seconds"
508
+ ) from exc
509
+ return self.client
510
+
511
+ @contextlib.contextmanager
512
+ def flood_budget(self, seconds: int | None) -> Any:
513
+ """Bound how long *this* request may sleep off a flood wait (COR-15).
514
+
515
+ Telethon reads `flood_sleep_threshold` off the client at call time, so
516
+ a per-request value is inherently shared between concurrent requests
517
+ on one account. Rather than serialise every request to make the flag
518
+ exact, the active budgets are kept on a stack and the **smallest** one
519
+ is in force: a caller that asked for at most five seconds is never
520
+ held for a hundred and twenty because somebody else's request had a
521
+ larger budget. The cost is that a generous caller may return sooner
522
+ than it had to, which is the safe direction.
523
+ """
524
+ if seconds is None or self.client is None:
525
+ yield
526
+ return
527
+ self._flood_budgets.append(max(0, int(seconds)))
528
+ original = getattr(self.client, "flood_sleep_threshold", None)
529
+ try:
530
+ self.client.flood_sleep_threshold = min(self._flood_budgets)
531
+ yield
532
+ finally:
533
+ with contextlib.suppress(ValueError):
534
+ self._flood_budgets.remove(max(0, int(seconds)))
535
+ if self._flood_budgets:
536
+ self.client.flood_sleep_threshold = min(self._flood_budgets)
537
+ elif original is not None:
538
+ self.client.flood_sleep_threshold = original
539
+
540
+ def note_update(self) -> None:
541
+ self.last_update = time.time()
542
+
543
+ # -- the job engine's view ---------------------------------------------
544
+
545
+ @property
546
+ def job_client(self) -> Any:
547
+ """This session as a `jobs.client.JobClient`.
548
+
549
+ Two methods, not a client wrapper: PR-12 deleted `ClientWrapper`,
550
+ whose 460 lines could log an account in and out from inside a
551
+ background job. The connection is owned here, and a job may attach
552
+ handlers, send, and turn a name into a chat id — nothing else.
553
+ """
554
+ if self._job_client is None:
555
+ self._job_client = _SessionJobClient(self)
556
+ return self._job_client
557
+
558
+ # -- reporting ---------------------------------------------------------
559
+
560
+ def snapshot(self) -> dict[str, Any]:
561
+ from datetime import datetime, timezone
562
+
563
+ def stamp(value: float | None) -> str | None:
564
+ if not value:
565
+ return None
566
+ return datetime.fromtimestamp(value, timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
567
+
568
+ info: dict[str, Any] = {
569
+ "alias": self.alias,
570
+ "state": self.state,
571
+ "user_id": getattr(self.me, "id", None),
572
+ "username": getattr(self.me, "username", None),
573
+ "connected_since": stamp(self.connected_since),
574
+ "last_update": stamp(self.last_update),
575
+ "reconnects": self.reconnects,
576
+ "catch_up_pending": self.catch_up_pending,
577
+ "in_flight": self.in_flight,
578
+ "resync_needed": list(self.resync_needed),
579
+ }
580
+ if self.reason:
581
+ info["reason"] = self.reason
582
+ if self.state != SessionState.ONLINE:
583
+ info["since"] = stamp(self.since)
584
+ return info
585
+
586
+
587
+ class _SessionJobClient:
588
+ """A `jobs.client.JobClient` backed by a supervised session.
589
+
590
+ Deliberately thin. The job engine's YAML names destinations by `@handle`,
591
+ so it needs a resolver; everything else it does, it does through the raw
592
+ Telethon client. Both read through to the session, so a reconnect swaps
593
+ the client underneath without the job noticing — which is what v1's
594
+ long-lived `ClientWrapper` could not do.
595
+ """
596
+
597
+ __slots__ = ("_session",)
598
+
599
+ def __init__(self, session: AccountSession) -> None:
600
+ self._session = session
601
+
602
+ @property
603
+ def client(self) -> Any:
604
+ return self._session.client
605
+
606
+ async def resolve_chat(self, chat_ref: str) -> int:
607
+ """`@channel`, an id or a link → the marked chat id.
608
+
609
+ Through the account's *own* resolver, never a shared one: an access
610
+ hash minted for one account is meaningless to another and produces
611
+ `PEER_ID_INVALID` for a peer that plainly exists (§6.6).
612
+ """
613
+ from tlgr.models.peer import parse_peer_ref
614
+ from tlgr.ops._serialize import peer_id_of
615
+
616
+ peer = await self._session.resolver.resolve(parse_peer_ref(str(chat_ref)))
617
+ found = peer_id_of(peer)
618
+ if found is None: # pragma: no cover - the resolver raises instead
619
+ raise ValueError(f"could not resolve {chat_ref!r}")
620
+ return int(found)
621
+
622
+
623
+ async def call_with_flood_budget(
624
+ client: Any,
625
+ request: Any,
626
+ *,
627
+ budget: int,
628
+ ) -> tuple[Any, int]:
629
+ """Run one raw request with a per-call flood budget (COR-15, ROB-06).
630
+
631
+ Telethon's `flood_sleep_threshold` is a client-wide attribute, so v1's
632
+ `--flood-wait-max` could not be honoured per request: whatever the last
633
+ caller set applied to everyone. Setting it around the call, and restoring
634
+ it after, is the only way to make the flag mean what it says.
635
+ """
636
+ previous = getattr(client, "flood_sleep_threshold", None)
637
+ slept_from = time.monotonic()
638
+ try:
639
+ client.flood_sleep_threshold = budget
640
+ result = await client(request)
641
+ finally:
642
+ if previous is not None:
643
+ client.flood_sleep_threshold = previous
644
+ slept = int(max(0.0, time.monotonic() - slept_from))
645
+ return result, slept
646
+
647
+
648
+ Supervisor = Callable[[], Awaitable[None]]