tlgr-cli 2.0.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (192) hide show
  1. tlgr/__init__.py +3 -0
  2. tlgr/__main__.py +6 -0
  3. tlgr/actions/__init__.py +45 -0
  4. tlgr/actions/forward.py +74 -0
  5. tlgr/actions/reply.py +32 -0
  6. tlgr/cli/__init__.py +259 -0
  7. tlgr/cli/confirm.py +55 -0
  8. tlgr/cli/errors.py +84 -0
  9. tlgr/cli/gen.py +690 -0
  10. tlgr/cli/globals.py +273 -0
  11. tlgr/cli/introspect.py +170 -0
  12. tlgr/cli/params.py +189 -0
  13. tlgr/cli/render.py +418 -0
  14. tlgr/core/__init__.py +0 -0
  15. tlgr/core/accounts.py +384 -0
  16. tlgr/core/config.py +358 -0
  17. tlgr/core/custom_tl.py +170 -0
  18. tlgr/core/errors.py +687 -0
  19. tlgr/core/eventtypes.py +1170 -0
  20. tlgr/core/identity.py +127 -0
  21. tlgr/core/launchd.py +122 -0
  22. tlgr/core/logging.py +194 -0
  23. tlgr/core/media.py +134 -0
  24. tlgr/core/output.py +251 -0
  25. tlgr/core/pagination.py +227 -0
  26. tlgr/core/paths.py +360 -0
  27. tlgr/core/peers.py +427 -0
  28. tlgr/core/process.py +138 -0
  29. tlgr/core/signing.py +38 -0
  30. tlgr/core/systemd.py +96 -0
  31. tlgr/core/telethon_compat.py +295 -0
  32. tlgr/core/text.py +211 -0
  33. tlgr/core/timefmt.py +199 -0
  34. tlgr/core/tl.py +98 -0
  35. tlgr/daemon/__init__.py +0 -0
  36. tlgr/daemon/app.py +869 -0
  37. tlgr/daemon/dispatch.py +446 -0
  38. tlgr/daemon/events.py +723 -0
  39. tlgr/daemon/files.py +431 -0
  40. tlgr/daemon/idle.py +119 -0
  41. tlgr/daemon/jobs.py +68 -0
  42. tlgr/daemon/main.py +161 -0
  43. tlgr/daemon/peercred.py +75 -0
  44. tlgr/daemon/policy.py +113 -0
  45. tlgr/daemon/preauth.py +366 -0
  46. tlgr/daemon/ratelimit.py +391 -0
  47. tlgr/daemon/server.py +24 -0
  48. tlgr/daemon/session.py +648 -0
  49. tlgr/daemon/sessions.py +274 -0
  50. tlgr/daemon/singleton.py +114 -0
  51. tlgr/daemon/stream.py +193 -0
  52. tlgr/daemon/transfers.py +219 -0
  53. tlgr/daemon/webhook.py +390 -0
  54. tlgr/data/catalog_index.json +1 -0
  55. tlgr/data/parity_waivers.toml +90 -0
  56. tlgr/filters/__init__.py +42 -0
  57. tlgr/filters/compose.py +121 -0
  58. tlgr/filters/content.py +85 -0
  59. tlgr/filters/context.py +114 -0
  60. tlgr/filters/message.py +161 -0
  61. tlgr/filters/temporal.py +87 -0
  62. tlgr/filters/user.py +36 -0
  63. tlgr/gateway/__init__.py +1 -0
  64. tlgr/gateway/config.py +161 -0
  65. tlgr/gateway/engine.py +215 -0
  66. tlgr/gateway/event.py +22 -0
  67. tlgr/jobs/__init__.py +0 -0
  68. tlgr/jobs/base.py +81 -0
  69. tlgr/jobs/client.py +37 -0
  70. tlgr/models/__init__.py +1220 -0
  71. tlgr/models/admin.py +744 -0
  72. tlgr/models/auth.py +510 -0
  73. tlgr/models/base.py +81 -0
  74. tlgr/models/bot.py +576 -0
  75. tlgr/models/business.py +265 -0
  76. tlgr/models/call.py +586 -0
  77. tlgr/models/config.py +101 -0
  78. tlgr/models/contact.py +481 -0
  79. tlgr/models/daemon.py +336 -0
  80. tlgr/models/dialog.py +626 -0
  81. tlgr/models/envelope.py +68 -0
  82. tlgr/models/error.py +30 -0
  83. tlgr/models/event.py +79 -0
  84. tlgr/models/export.py +66 -0
  85. tlgr/models/gift.py +275 -0
  86. tlgr/models/inline.py +84 -0
  87. tlgr/models/location.py +115 -0
  88. tlgr/models/media.py +507 -0
  89. tlgr/models/message.py +584 -0
  90. tlgr/models/net.py +232 -0
  91. tlgr/models/notify.py +105 -0
  92. tlgr/models/page.py +32 -0
  93. tlgr/models/payment.py +172 -0
  94. tlgr/models/peer.py +400 -0
  95. tlgr/models/poll.py +119 -0
  96. tlgr/models/premium.py +161 -0
  97. tlgr/models/privacy.py +93 -0
  98. tlgr/models/profile.py +217 -0
  99. tlgr/models/reaction.py +160 -0
  100. tlgr/models/resolve.py +175 -0
  101. tlgr/models/settings.py +103 -0
  102. tlgr/models/stars.py +101 -0
  103. tlgr/models/sticker.py +243 -0
  104. tlgr/models/story.py +467 -0
  105. tlgr/models/sync.py +105 -0
  106. tlgr/models/todo.py +36 -0
  107. tlgr/models/webapp.py +89 -0
  108. tlgr/ops/__init__.py +63 -0
  109. tlgr/ops/_admin.py +313 -0
  110. tlgr/ops/_auth.py +599 -0
  111. tlgr/ops/_bots.py +586 -0
  112. tlgr/ops/_calls.py +535 -0
  113. tlgr/ops/_common.py +160 -0
  114. tlgr/ops/_layer.py +46 -0
  115. tlgr/ops/_media.py +592 -0
  116. tlgr/ops/_params.py +212 -0
  117. tlgr/ops/_rights.py +402 -0
  118. tlgr/ops/_send.py +593 -0
  119. tlgr/ops/_serialize.py +667 -0
  120. tlgr/ops/_settings.py +306 -0
  121. tlgr/ops/_spec.py +167 -0
  122. tlgr/ops/_story.py +743 -0
  123. tlgr/ops/account.py +2604 -0
  124. tlgr/ops/agent.py +937 -0
  125. tlgr/ops/auth.py +1282 -0
  126. tlgr/ops/bot.py +4880 -0
  127. tlgr/ops/business.py +1520 -0
  128. tlgr/ops/call.py +1610 -0
  129. tlgr/ops/chat.py +4025 -0
  130. tlgr/ops/chat_admin.py +929 -0
  131. tlgr/ops/chat_extra.py +1061 -0
  132. tlgr/ops/chat_invite.py +716 -0
  133. tlgr/ops/chat_manage.py +1691 -0
  134. tlgr/ops/chat_member.py +1357 -0
  135. tlgr/ops/chat_stats.py +902 -0
  136. tlgr/ops/chat_topic.py +905 -0
  137. tlgr/ops/conference.py +791 -0
  138. tlgr/ops/config.py +1698 -0
  139. tlgr/ops/contact.py +2330 -0
  140. tlgr/ops/daemon.py +1397 -0
  141. tlgr/ops/draft.py +299 -0
  142. tlgr/ops/emoji.py +343 -0
  143. tlgr/ops/events.py +1327 -0
  144. tlgr/ops/export.py +596 -0
  145. tlgr/ops/folder.py +1322 -0
  146. tlgr/ops/gif.py +522 -0
  147. tlgr/ops/gift.py +1546 -0
  148. tlgr/ops/giveaway.py +541 -0
  149. tlgr/ops/inline.py +773 -0
  150. tlgr/ops/job.py +799 -0
  151. tlgr/ops/location.py +917 -0
  152. tlgr/ops/media.py +4495 -0
  153. tlgr/ops/message.py +3769 -0
  154. tlgr/ops/net.py +536 -0
  155. tlgr/ops/notify.py +840 -0
  156. tlgr/ops/passport.py +464 -0
  157. tlgr/ops/payment.py +907 -0
  158. tlgr/ops/poll.py +1078 -0
  159. tlgr/ops/premium.py +488 -0
  160. tlgr/ops/privacy.py +794 -0
  161. tlgr/ops/profile.py +1481 -0
  162. tlgr/ops/proxy.py +750 -0
  163. tlgr/ops/reaction.py +1475 -0
  164. tlgr/ops/resolve.py +1140 -0
  165. tlgr/ops/search.py +521 -0
  166. tlgr/ops/settings.py +1066 -0
  167. tlgr/ops/stars.py +594 -0
  168. tlgr/ops/sticker.py +1602 -0
  169. tlgr/ops/story.py +3216 -0
  170. tlgr/ops/sync.py +788 -0
  171. tlgr/ops/todo.py +514 -0
  172. tlgr/ops/user.py +1406 -0
  173. tlgr/ops/vc.py +2351 -0
  174. tlgr/ops/webapp.py +717 -0
  175. tlgr/ops/webhook.py +418 -0
  176. tlgr/parity.py +386 -0
  177. tlgr/processors/__init__.py +125 -0
  178. tlgr/processors/regex.py +26 -0
  179. tlgr/processors/text.py +56 -0
  180. tlgr/registry.py +519 -0
  181. tlgr/schema.py +173 -0
  182. tlgr/transport/__init__.py +30 -0
  183. tlgr/transport/autostart.py +293 -0
  184. tlgr/transport/client.py +805 -0
  185. tlgr/transport/ndjson.py +44 -0
  186. tlgr/version.py +31 -0
  187. tlgr_cli-2.0.1.dist-info/METADATA +957 -0
  188. tlgr_cli-2.0.1.dist-info/RECORD +192 -0
  189. tlgr_cli-2.0.1.dist-info/WHEEL +5 -0
  190. tlgr_cli-2.0.1.dist-info/entry_points.txt +2 -0
  191. tlgr_cli-2.0.1.dist-info/licenses/LICENSE +21 -0
  192. tlgr_cli-2.0.1.dist-info/top_level.txt +1 -0
tlgr/daemon/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