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