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/sessions.py
ADDED
|
@@ -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}")
|
tlgr/daemon/singleton.py
ADDED
|
@@ -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")
|