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/files.py
ADDED
|
@@ -0,0 +1,431 @@
|
|
|
1
|
+
"""Download and upload pipelines (§6.7, checklist 13/14).
|
|
2
|
+
|
|
3
|
+
Telethon's `download_media`/`send_file` are fine for a script and wrong for a
|
|
4
|
+
daemon. What is missing, and what this module adds:
|
|
5
|
+
|
|
6
|
+
* **the message is the source of truth.** A `file_reference` expires in
|
|
7
|
+
minutes to hours. Downloading from a `Message` object a caller fetched an
|
|
8
|
+
hour ago fails with `FILE_REFERENCE_EXPIRED`, and the only fix is to
|
|
9
|
+
re-fetch the message — so the transfer keeps `(chat_id, msg_id)` and can
|
|
10
|
+
refresh itself once, for photos and profile photos too (Telethon only does
|
|
11
|
+
documents).
|
|
12
|
+
* **resume.** A 2 GB download that dies at 90 % starts again from zero.
|
|
13
|
+
Parts are written to `<target>.part`, fsynced periodically, and the next
|
|
14
|
+
attempt starts at the existing size.
|
|
15
|
+
* **concurrency that matches the server's.** `help.getAppConfig` publishes
|
|
16
|
+
`small/large_queue_max_active_operations_count`; exceeding it earns a flood
|
|
17
|
+
wait. A semaphore per `(account, dc_id)` caps large and small transfers
|
|
18
|
+
separately.
|
|
19
|
+
* **uploads in parallel.** Telethon uploads parts strictly sequentially, so a
|
|
20
|
+
large file is bounded by round-trip latency rather than bandwidth. A sliding
|
|
21
|
+
window of 3–4 `upload.saveBigFilePart` calls is the single biggest speed-up
|
|
22
|
+
available, and the part size rules come straight from
|
|
23
|
+
`utils.get_appropriated_part_size`.
|
|
24
|
+
|
|
25
|
+
Stage C wires these to the `media` operations; what lands here is the
|
|
26
|
+
machinery and its error handling.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
from __future__ import annotations
|
|
30
|
+
|
|
31
|
+
import asyncio
|
|
32
|
+
import contextlib
|
|
33
|
+
import hashlib
|
|
34
|
+
import logging
|
|
35
|
+
import os
|
|
36
|
+
import time
|
|
37
|
+
from collections.abc import AsyncIterator, Awaitable, Callable
|
|
38
|
+
from dataclasses import dataclass, field
|
|
39
|
+
from pathlib import Path
|
|
40
|
+
from typing import Any
|
|
41
|
+
|
|
42
|
+
from tlgr.core.errors import TlgrError, UsageError
|
|
43
|
+
from tlgr.core.media import infer_attributes as _infer_attributes
|
|
44
|
+
|
|
45
|
+
log = logging.getLogger("tlgr.daemon.files")
|
|
46
|
+
|
|
47
|
+
__all__ = [
|
|
48
|
+
"DownloadPlan",
|
|
49
|
+
"TransferSlots",
|
|
50
|
+
"UploadPlan",
|
|
51
|
+
"download",
|
|
52
|
+
"part_size_for",
|
|
53
|
+
"upload",
|
|
54
|
+
]
|
|
55
|
+
|
|
56
|
+
#: 512 KB is the largest part Telegram accepts and the size every official
|
|
57
|
+
#: client uses for anything but a thumbnail.
|
|
58
|
+
DEFAULT_PART_SIZE = 512 * 1024
|
|
59
|
+
_LARGE_FILE = 10 * 1024 * 1024
|
|
60
|
+
_BIG_UPLOAD = 10 * 1024 * 1024
|
|
61
|
+
_FSYNC_EVERY = 8 * 1024 * 1024
|
|
62
|
+
_PROGRESS_INTERVAL = 1.0
|
|
63
|
+
_PROGRESS_BYTES = 1024 * 1024
|
|
64
|
+
|
|
65
|
+
#: Anything at or above this counts against the "large" transfer budget.
|
|
66
|
+
LARGE_TRANSFER_BYTES = 20 * 1024 * 1024
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def part_size_for(size: int) -> int:
|
|
70
|
+
"""`utils.get_appropriated_part_size`, in the units Telegram requires.
|
|
71
|
+
|
|
72
|
+
The part size must divide 1 MB and every part except the last must be the
|
|
73
|
+
same size; getting it wrong is `FILE_PART_SIZE_INVALID` after the upload
|
|
74
|
+
has already spent the bandwidth.
|
|
75
|
+
"""
|
|
76
|
+
if size <= 104857600: # 100 MB
|
|
77
|
+
return 128 * 1024 if size <= 10 * 1024 * 1024 else DEFAULT_PART_SIZE
|
|
78
|
+
return DEFAULT_PART_SIZE
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class TransferSlots:
|
|
82
|
+
"""Per-DC concurrency caps (§6.7).
|
|
83
|
+
|
|
84
|
+
Two budgets, not one: a single 2 GB download must not be able to starve
|
|
85
|
+
the five small thumbnail fetches a chat list needs.
|
|
86
|
+
"""
|
|
87
|
+
|
|
88
|
+
def __init__(self, *, small: int = 5, large: int = 2) -> None:
|
|
89
|
+
self._small_limit = max(1, small)
|
|
90
|
+
self._large_limit = max(1, large)
|
|
91
|
+
self._small: dict[int, asyncio.Semaphore] = {}
|
|
92
|
+
self._large: dict[int, asyncio.Semaphore] = {}
|
|
93
|
+
|
|
94
|
+
def slot(self, dc_id: int, size: int) -> asyncio.Semaphore:
|
|
95
|
+
pool = self._large if size >= LARGE_TRANSFER_BYTES else self._small
|
|
96
|
+
limit = self._large_limit if size >= LARGE_TRANSFER_BYTES else self._small_limit
|
|
97
|
+
if dc_id not in pool:
|
|
98
|
+
pool[dc_id] = asyncio.Semaphore(limit)
|
|
99
|
+
return pool[dc_id]
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
Progress = Callable[[int, int], Any]
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
@dataclass
|
|
106
|
+
class DownloadPlan:
|
|
107
|
+
"""One download, with everything needed to restart or refresh it."""
|
|
108
|
+
|
|
109
|
+
target: Path
|
|
110
|
+
chat_id: int | None = None
|
|
111
|
+
message_id: int | None = None
|
|
112
|
+
size: int = 0
|
|
113
|
+
dc_id: int = 0
|
|
114
|
+
offset: int = 0
|
|
115
|
+
limit: int | None = None
|
|
116
|
+
resume: bool = True
|
|
117
|
+
part_size: int = DEFAULT_PART_SIZE
|
|
118
|
+
#: Ranged readers running side by side. One is the safe default; the cap
|
|
119
|
+
#: that matters is the server's (`*_queue_max_active_operations_count`),
|
|
120
|
+
#: and exceeding it earns a flood wait rather than more bandwidth.
|
|
121
|
+
connections: int = 1
|
|
122
|
+
|
|
123
|
+
@property
|
|
124
|
+
def part_file(self) -> Path:
|
|
125
|
+
return self.target.with_suffix(self.target.suffix + ".part")
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
class _Throttle:
|
|
129
|
+
"""Emit progress at most once a second or once a megabyte."""
|
|
130
|
+
|
|
131
|
+
def __init__(self) -> None:
|
|
132
|
+
self._last_time = 0.0
|
|
133
|
+
self._last_bytes = 0
|
|
134
|
+
|
|
135
|
+
def should(self, done: int) -> bool:
|
|
136
|
+
now = time.monotonic()
|
|
137
|
+
if (
|
|
138
|
+
now - self._last_time >= _PROGRESS_INTERVAL
|
|
139
|
+
or done - self._last_bytes >= _PROGRESS_BYTES
|
|
140
|
+
):
|
|
141
|
+
self._last_time = now
|
|
142
|
+
self._last_bytes = done
|
|
143
|
+
return True
|
|
144
|
+
return False
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
async def download(
|
|
148
|
+
client: Any,
|
|
149
|
+
location: Any,
|
|
150
|
+
plan: DownloadPlan,
|
|
151
|
+
*,
|
|
152
|
+
slots: TransferSlots | None = None,
|
|
153
|
+
progress: Progress | None = None,
|
|
154
|
+
refresh: Callable[[], Awaitable[Any]] | None = None,
|
|
155
|
+
) -> Path:
|
|
156
|
+
"""Stream *location* into `plan.target`, resuming and refreshing as needed.
|
|
157
|
+
|
|
158
|
+
`refresh()` re-fetches the source message and returns a fresh location; it
|
|
159
|
+
is called at most once, because a second `FILE_REFERENCE_EXPIRED` after a
|
|
160
|
+
refresh means something other than an expired reference.
|
|
161
|
+
"""
|
|
162
|
+
plan.target.parent.mkdir(parents=True, exist_ok=True)
|
|
163
|
+
if plan.connections > 1 and plan.size:
|
|
164
|
+
return await _download_striped(client, location, plan, slots=slots, progress=progress)
|
|
165
|
+
part = plan.part_file
|
|
166
|
+
start = plan.offset
|
|
167
|
+
if plan.resume and part.exists():
|
|
168
|
+
# Round down to a whole part: resuming mid-part would ask the server
|
|
169
|
+
# for an offset it will reject (`OFFSET_INVALID`).
|
|
170
|
+
existing = part.stat().st_size
|
|
171
|
+
start = max(start, existing - (existing % plan.part_size))
|
|
172
|
+
with contextlib.suppress(OSError):
|
|
173
|
+
os.truncate(part, start)
|
|
174
|
+
|
|
175
|
+
slot = (slots or TransferSlots()).slot(plan.dc_id, plan.size)
|
|
176
|
+
done = start
|
|
177
|
+
throttle = _Throttle()
|
|
178
|
+
refreshed = False
|
|
179
|
+
|
|
180
|
+
async with slot:
|
|
181
|
+
while True:
|
|
182
|
+
try:
|
|
183
|
+
with open(part, "ab") as handle:
|
|
184
|
+
since_sync = 0
|
|
185
|
+
async for chunk in client.iter_download(
|
|
186
|
+
location,
|
|
187
|
+
offset=start,
|
|
188
|
+
request_size=plan.part_size,
|
|
189
|
+
limit=plan.limit,
|
|
190
|
+
):
|
|
191
|
+
handle.write(chunk)
|
|
192
|
+
done += len(chunk)
|
|
193
|
+
since_sync += len(chunk)
|
|
194
|
+
if since_sync >= _FSYNC_EVERY:
|
|
195
|
+
handle.flush()
|
|
196
|
+
os.fsync(handle.fileno())
|
|
197
|
+
since_sync = 0
|
|
198
|
+
if progress is not None and throttle.should(done):
|
|
199
|
+
progress(done, plan.size)
|
|
200
|
+
handle.flush()
|
|
201
|
+
os.fsync(handle.fileno())
|
|
202
|
+
break
|
|
203
|
+
except Exception as exc:
|
|
204
|
+
if refreshed or refresh is None or not _is_file_reference_error(exc):
|
|
205
|
+
raise
|
|
206
|
+
refreshed = True
|
|
207
|
+
log.info("refreshing an expired file reference and retrying once")
|
|
208
|
+
location = await refresh()
|
|
209
|
+
start = done
|
|
210
|
+
|
|
211
|
+
# A ranged read is *meant* to stop short; only a whole-file download that
|
|
212
|
+
# ended early is an incomplete download.
|
|
213
|
+
if plan.size and plan.limit is None and done < plan.size:
|
|
214
|
+
raise TlgrError(
|
|
215
|
+
f"the download stopped at {done} of {plan.size} bytes; the file is incomplete"
|
|
216
|
+
)
|
|
217
|
+
os.replace(part, plan.target)
|
|
218
|
+
if progress is not None:
|
|
219
|
+
progress(done, plan.size or done)
|
|
220
|
+
return plan.target
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
async def _download_striped(
|
|
224
|
+
client: Any,
|
|
225
|
+
location: Any,
|
|
226
|
+
plan: DownloadPlan,
|
|
227
|
+
*,
|
|
228
|
+
slots: TransferSlots | None = None,
|
|
229
|
+
progress: Progress | None = None,
|
|
230
|
+
) -> Path:
|
|
231
|
+
"""`--connections N`: N ranged readers over one file (checklist 13).
|
|
232
|
+
|
|
233
|
+
A single `iter_download` is bounded by round-trip latency, not bandwidth.
|
|
234
|
+
Telegram serves an arbitrary offset, so the file is cut into N contiguous
|
|
235
|
+
stripes written with `pwrite` — no locking, no ordering, and a stripe that
|
|
236
|
+
fails takes only itself down.
|
|
237
|
+
|
|
238
|
+
Deliberately *not* resumable: a striped download has N frontiers rather
|
|
239
|
+
than one, and a `.part` file that records only a single offset would
|
|
240
|
+
silently resume in the wrong place. `--resume` uses one connection.
|
|
241
|
+
"""
|
|
242
|
+
total = plan.size
|
|
243
|
+
workers = max(1, min(plan.connections, 8))
|
|
244
|
+
stripe = -(-total // workers)
|
|
245
|
+
slot = (slots or TransferSlots()).slot(plan.dc_id, total)
|
|
246
|
+
done = 0
|
|
247
|
+
throttle = _Throttle()
|
|
248
|
+
lock = asyncio.Lock()
|
|
249
|
+
|
|
250
|
+
with open(plan.part_file, "wb") as handle:
|
|
251
|
+
handle.truncate(total)
|
|
252
|
+
descriptor = handle.fileno()
|
|
253
|
+
|
|
254
|
+
async def read(index: int) -> None:
|
|
255
|
+
nonlocal done
|
|
256
|
+
start = index * stripe
|
|
257
|
+
length = min(stripe, total - start)
|
|
258
|
+
if length <= 0:
|
|
259
|
+
return
|
|
260
|
+
position = start
|
|
261
|
+
async for chunk in client.iter_download(
|
|
262
|
+
location, offset=start, request_size=plan.part_size, limit=length
|
|
263
|
+
):
|
|
264
|
+
os.pwrite(descriptor, chunk, position)
|
|
265
|
+
position += len(chunk)
|
|
266
|
+
async with lock:
|
|
267
|
+
done += len(chunk)
|
|
268
|
+
if progress is not None and throttle.should(done):
|
|
269
|
+
progress(done, total)
|
|
270
|
+
if position - start >= length:
|
|
271
|
+
break
|
|
272
|
+
|
|
273
|
+
async with slot:
|
|
274
|
+
await asyncio.gather(*(read(index) for index in range(workers)))
|
|
275
|
+
handle.flush()
|
|
276
|
+
os.fsync(descriptor)
|
|
277
|
+
|
|
278
|
+
os.replace(plan.part_file, plan.target)
|
|
279
|
+
if progress is not None:
|
|
280
|
+
progress(total, total)
|
|
281
|
+
return plan.target
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def _is_file_reference_error(exc: BaseException) -> bool:
|
|
285
|
+
name = type(exc).__name__
|
|
286
|
+
if "FileReference" in name or "FilerefUpgradeNeeded" in name:
|
|
287
|
+
return True
|
|
288
|
+
return "FILE_REFERENCE_" in str(exc).upper()
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def file_reference_index(message: str) -> int | None:
|
|
292
|
+
"""`FILE_REFERENCE_3_EXPIRED` → 3, so an album refreshes only that item."""
|
|
293
|
+
import re
|
|
294
|
+
|
|
295
|
+
match = re.search(r"FILE_REFERENCE_(\d+)_EXPIRED", message.upper())
|
|
296
|
+
return int(match.group(1)) if match else None
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
@dataclass
|
|
300
|
+
class UploadPlan:
|
|
301
|
+
source: Path
|
|
302
|
+
file_name: str = ""
|
|
303
|
+
part_size: int = 0
|
|
304
|
+
parts_in_flight: int = 4
|
|
305
|
+
max_parts: int = 4000
|
|
306
|
+
file_id: int = 0
|
|
307
|
+
attributes: list[Any] = field(default_factory=list)
|
|
308
|
+
|
|
309
|
+
def __post_init__(self) -> None:
|
|
310
|
+
if not self.file_name:
|
|
311
|
+
self.file_name = self.source.name
|
|
312
|
+
if not self.part_size:
|
|
313
|
+
self.part_size = part_size_for(self.size)
|
|
314
|
+
if not self.file_id:
|
|
315
|
+
self.file_id = int.from_bytes(os.urandom(8), "big", signed=True)
|
|
316
|
+
|
|
317
|
+
@property
|
|
318
|
+
def size(self) -> int:
|
|
319
|
+
return self.source.stat().st_size if self.source.exists() else 0
|
|
320
|
+
|
|
321
|
+
@property
|
|
322
|
+
def total_parts(self) -> int:
|
|
323
|
+
size = self.size
|
|
324
|
+
return max(1, -(-size // self.part_size))
|
|
325
|
+
|
|
326
|
+
@property
|
|
327
|
+
def big(self) -> bool:
|
|
328
|
+
return self.size > _BIG_UPLOAD
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
async def upload(
|
|
332
|
+
client: Any,
|
|
333
|
+
plan: UploadPlan,
|
|
334
|
+
*,
|
|
335
|
+
progress: Progress | None = None,
|
|
336
|
+
max_parts_allowed: int = 4000,
|
|
337
|
+
) -> Any:
|
|
338
|
+
"""Upload a file with a sliding window of parts in flight (checklist 14).
|
|
339
|
+
|
|
340
|
+
The pre-flight check matters more than the speed: a file that cannot fit
|
|
341
|
+
in `upload_max_fileparts_*` is a USAGE error *before* a byte is sent,
|
|
342
|
+
rather than a failure after twenty minutes of upload.
|
|
343
|
+
"""
|
|
344
|
+
from telethon.tl.functions.upload import SaveBigFilePartRequest, SaveFilePartRequest
|
|
345
|
+
from telethon.tl.types import InputFile, InputFileBig
|
|
346
|
+
|
|
347
|
+
if not plan.source.exists():
|
|
348
|
+
raise UsageError(f"{plan.source} does not exist", field="path")
|
|
349
|
+
total = plan.total_parts
|
|
350
|
+
if total > max_parts_allowed:
|
|
351
|
+
raise UsageError(
|
|
352
|
+
f"{plan.source.name} needs {total} parts and this account may upload "
|
|
353
|
+
f"{max_parts_allowed}; the file is too large"
|
|
354
|
+
)
|
|
355
|
+
|
|
356
|
+
md5 = hashlib.md5() if not plan.big and plan.size <= _LARGE_FILE else None
|
|
357
|
+
window = asyncio.Semaphore(max(1, plan.parts_in_flight))
|
|
358
|
+
sent = 0
|
|
359
|
+
throttle = _Throttle()
|
|
360
|
+
lock = asyncio.Lock()
|
|
361
|
+
|
|
362
|
+
async def send_part(index: int, chunk: bytes) -> None:
|
|
363
|
+
nonlocal sent
|
|
364
|
+
async with window:
|
|
365
|
+
if plan.big:
|
|
366
|
+
request: Any = SaveBigFilePartRequest(
|
|
367
|
+
file_id=plan.file_id,
|
|
368
|
+
file_part=index,
|
|
369
|
+
file_total_parts=total,
|
|
370
|
+
bytes=chunk,
|
|
371
|
+
)
|
|
372
|
+
else:
|
|
373
|
+
request = SaveFilePartRequest(file_id=plan.file_id, file_part=index, bytes=chunk)
|
|
374
|
+
await client(request)
|
|
375
|
+
async with lock:
|
|
376
|
+
sent += len(chunk)
|
|
377
|
+
if progress is not None and throttle.should(sent):
|
|
378
|
+
progress(sent, plan.size)
|
|
379
|
+
|
|
380
|
+
tasks: list[asyncio.Task[None]] = []
|
|
381
|
+
with open(plan.source, "rb") as handle:
|
|
382
|
+
index = 0
|
|
383
|
+
while True:
|
|
384
|
+
chunk = handle.read(plan.part_size)
|
|
385
|
+
if not chunk:
|
|
386
|
+
break
|
|
387
|
+
if md5 is not None:
|
|
388
|
+
md5.update(chunk)
|
|
389
|
+
tasks.append(asyncio.create_task(send_part(index, chunk)))
|
|
390
|
+
index += 1
|
|
391
|
+
# Keep the window bounded in *memory* too: without this the whole
|
|
392
|
+
# file would be read into a list of pending tasks.
|
|
393
|
+
if len(tasks) >= plan.parts_in_flight * 2:
|
|
394
|
+
await asyncio.gather(*tasks)
|
|
395
|
+
tasks = []
|
|
396
|
+
if tasks:
|
|
397
|
+
await asyncio.gather(*tasks)
|
|
398
|
+
|
|
399
|
+
if progress is not None:
|
|
400
|
+
progress(plan.size, plan.size)
|
|
401
|
+
if plan.big:
|
|
402
|
+
return InputFileBig(id=plan.file_id, parts=total, name=plan.file_name)
|
|
403
|
+
return InputFile(
|
|
404
|
+
id=plan.file_id,
|
|
405
|
+
parts=total,
|
|
406
|
+
name=plan.file_name,
|
|
407
|
+
md5_checksum=md5.hexdigest() if md5 else "",
|
|
408
|
+
)
|
|
409
|
+
|
|
410
|
+
|
|
411
|
+
# ---------------------------------------------------------------------------
|
|
412
|
+
# Attribute inference
|
|
413
|
+
# ---------------------------------------------------------------------------
|
|
414
|
+
|
|
415
|
+
#: Re-exported from `core/media.py`, where the send path can also reach it.
|
|
416
|
+
infer_attributes = _infer_attributes
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
async def iter_progress_frames(
|
|
420
|
+
transfer: AsyncIterator[tuple[int, int]],
|
|
421
|
+
) -> AsyncIterator[dict[str, Any]]: # pragma: no cover - wired to ops in stage C
|
|
422
|
+
"""Turn `(done, total)` updates into the `progress` frames of §5.3."""
|
|
423
|
+
started = time.monotonic()
|
|
424
|
+
async for done, total in transfer:
|
|
425
|
+
elapsed = max(1e-6, time.monotonic() - started)
|
|
426
|
+
yield {
|
|
427
|
+
"type": "progress",
|
|
428
|
+
"done": done,
|
|
429
|
+
"total": total,
|
|
430
|
+
"rate_bps": int(done / elapsed),
|
|
431
|
+
}
|
tlgr/daemon/idle.py
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
"""Whether the daemon is doing anything, and whether it may stop (COR-08/11).
|
|
2
|
+
|
|
3
|
+
v1 counted "seconds since the last IPC request" and nothing else, so:
|
|
4
|
+
|
|
5
|
+
* a `chat posters` scan ten minutes into a dialog walk was idle, and the
|
|
6
|
+
monitor killed it mid-flight (COR-11);
|
|
7
|
+
* a daemon whose only job was pushing webhooks was idle by definition, so it
|
|
8
|
+
stopped and the webhook silently stopped with it (COR-08);
|
|
9
|
+
* an open `tlgr watch` stream was idle for as long as the chat was quiet.
|
|
10
|
+
|
|
11
|
+
Activity here is the union of everything that would be lost by exiting: in
|
|
12
|
+
flight requests, open event streams, running file transfers, running jobs, an
|
|
13
|
+
enabled webhook, and a pending login. `idle_timeout` counts only while all of
|
|
14
|
+
them are zero, and the monitor refuses to stop while the in-flight counter is
|
|
15
|
+
non-zero even if the clock says otherwise.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import time
|
|
21
|
+
from dataclasses import dataclass, field
|
|
22
|
+
|
|
23
|
+
__all__ = ["ActivityTracker"]
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass
|
|
27
|
+
class ActivityTracker:
|
|
28
|
+
"""Counters plus a last-activity clock. Cheap enough to touch per request."""
|
|
29
|
+
|
|
30
|
+
in_flight: int = 0
|
|
31
|
+
event_streams: int = 0
|
|
32
|
+
transfers: int = 0
|
|
33
|
+
pending_logins: int = 0
|
|
34
|
+
webhook_enabled: bool = False
|
|
35
|
+
jobs_running: int = 0
|
|
36
|
+
last_activity: float = field(default_factory=time.monotonic)
|
|
37
|
+
last_request_at: float = 0.0
|
|
38
|
+
|
|
39
|
+
def touch(self) -> None:
|
|
40
|
+
self.last_activity = time.monotonic()
|
|
41
|
+
self.last_request_at = time.time()
|
|
42
|
+
|
|
43
|
+
# -- scopes ------------------------------------------------------------
|
|
44
|
+
|
|
45
|
+
def begin_request(self) -> None:
|
|
46
|
+
self.in_flight += 1
|
|
47
|
+
self.touch()
|
|
48
|
+
|
|
49
|
+
def end_request(self) -> None:
|
|
50
|
+
self.in_flight = max(0, self.in_flight - 1)
|
|
51
|
+
self.touch()
|
|
52
|
+
|
|
53
|
+
def begin_stream(self) -> None:
|
|
54
|
+
self.event_streams += 1
|
|
55
|
+
self.touch()
|
|
56
|
+
|
|
57
|
+
def end_stream(self) -> None:
|
|
58
|
+
self.event_streams = max(0, self.event_streams - 1)
|
|
59
|
+
self.touch()
|
|
60
|
+
|
|
61
|
+
def begin_transfer(self) -> None:
|
|
62
|
+
self.transfers += 1
|
|
63
|
+
self.touch()
|
|
64
|
+
|
|
65
|
+
def end_transfer(self) -> None:
|
|
66
|
+
self.transfers = max(0, self.transfers - 1)
|
|
67
|
+
self.touch()
|
|
68
|
+
|
|
69
|
+
# -- decision ----------------------------------------------------------
|
|
70
|
+
|
|
71
|
+
@property
|
|
72
|
+
def busy(self) -> bool:
|
|
73
|
+
return bool(
|
|
74
|
+
self.in_flight
|
|
75
|
+
or self.event_streams
|
|
76
|
+
or self.transfers
|
|
77
|
+
or self.pending_logins
|
|
78
|
+
or self.jobs_running
|
|
79
|
+
or self.webhook_enabled
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
def idle_seconds(self) -> float:
|
|
83
|
+
if self.busy:
|
|
84
|
+
return 0.0
|
|
85
|
+
return max(0.0, time.monotonic() - self.last_activity)
|
|
86
|
+
|
|
87
|
+
def may_stop(self, idle_timeout: int) -> bool:
|
|
88
|
+
"""True only when nothing is happening and nothing happened recently."""
|
|
89
|
+
if idle_timeout <= 0:
|
|
90
|
+
return False
|
|
91
|
+
if self.busy:
|
|
92
|
+
return False
|
|
93
|
+
return self.idle_seconds() >= idle_timeout
|
|
94
|
+
|
|
95
|
+
def snapshot(self) -> dict[str, object]:
|
|
96
|
+
return {
|
|
97
|
+
"in_flight": self.in_flight,
|
|
98
|
+
"event_streams": self.event_streams,
|
|
99
|
+
"transfers": self.transfers,
|
|
100
|
+
"pending_logins": self.pending_logins,
|
|
101
|
+
"jobs_running": self.jobs_running,
|
|
102
|
+
"webhook": self.webhook_enabled,
|
|
103
|
+
"idle_seconds": round(self.idle_seconds(), 1),
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def effective_idle_timeout(configured: int, *, webhook_enabled: bool, managed_by: str = "") -> int:
|
|
108
|
+
"""§6.10: forced to 0 when stopping would break something.
|
|
109
|
+
|
|
110
|
+
A webhook subscriber that exits has silently unsubscribed. Under
|
|
111
|
+
launchd/systemd an idle exit is either respawned immediately (churn) or,
|
|
112
|
+
with `KeepAlive.SuccessfulExit=false`, never respawned at all — which is
|
|
113
|
+
COR-39, and is why the plist also forces this to 0.
|
|
114
|
+
"""
|
|
115
|
+
if webhook_enabled:
|
|
116
|
+
return 0
|
|
117
|
+
if managed_by in ("launchd", "systemd"):
|
|
118
|
+
return 0
|
|
119
|
+
return max(0, configured)
|
tlgr/daemon/jobs.py
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""Job runner — manages lifecycle of background jobs."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from tlgr.daemon.webhook import WebhookPusher
|
|
9
|
+
from tlgr.gateway.config import GatewayConfig
|
|
10
|
+
from tlgr.gateway.engine import Gateway
|
|
11
|
+
from tlgr.jobs.base import BaseJob
|
|
12
|
+
from tlgr.jobs.client import JobClient
|
|
13
|
+
|
|
14
|
+
log = logging.getLogger("tlgr.daemon.jobs")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class JobRunner:
|
|
18
|
+
def __init__(self):
|
|
19
|
+
self._jobs: dict[str, BaseJob] = {}
|
|
20
|
+
|
|
21
|
+
def create_job(
|
|
22
|
+
self,
|
|
23
|
+
config: GatewayConfig,
|
|
24
|
+
client: JobClient,
|
|
25
|
+
webhook: WebhookPusher | None = None,
|
|
26
|
+
bus: Any = None,
|
|
27
|
+
) -> BaseJob:
|
|
28
|
+
job = Gateway(config, client, webhook, bus)
|
|
29
|
+
self._jobs[config.name] = job
|
|
30
|
+
return job
|
|
31
|
+
|
|
32
|
+
async def start_all(self) -> None:
|
|
33
|
+
for name, job in self._jobs.items():
|
|
34
|
+
if job.enabled:
|
|
35
|
+
job.start()
|
|
36
|
+
log.info("Started job: %s", name)
|
|
37
|
+
|
|
38
|
+
async def stop_all(self) -> None:
|
|
39
|
+
for name, job in self._jobs.items():
|
|
40
|
+
await job.stop()
|
|
41
|
+
log.info("Stopped job: %s", name)
|
|
42
|
+
|
|
43
|
+
def list_jobs(self) -> list[dict[str, Any]]:
|
|
44
|
+
return [j.status() for j in self._jobs.values()]
|
|
45
|
+
|
|
46
|
+
async def remove_job(self, name: str) -> bool:
|
|
47
|
+
job = self._jobs.pop(name, None)
|
|
48
|
+
if job is None:
|
|
49
|
+
return False
|
|
50
|
+
await job.stop()
|
|
51
|
+
return True
|
|
52
|
+
|
|
53
|
+
async def enable_job(self, name: str) -> bool:
|
|
54
|
+
job = self._jobs.get(name)
|
|
55
|
+
if job is None:
|
|
56
|
+
return False
|
|
57
|
+
if not job.enabled:
|
|
58
|
+
job.enabled = True
|
|
59
|
+
job.start()
|
|
60
|
+
return True
|
|
61
|
+
|
|
62
|
+
async def disable_job(self, name: str) -> bool:
|
|
63
|
+
job = self._jobs.get(name)
|
|
64
|
+
if job is None:
|
|
65
|
+
return False
|
|
66
|
+
job.enabled = False
|
|
67
|
+
await job.stop()
|
|
68
|
+
return True
|