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/ops/media.py ADDED
@@ -0,0 +1,4495 @@
1
+ """The `media` group: bytes in, bytes out, and everything that describes them.
2
+
3
+ The group exists because a file is not a message. v1 had two commands —
4
+ `media download <chat> <id>` and `media upload <chat> <path>` — each a single
5
+ Telethon call with no resume, no progress, no album, no thumbnail, no size
6
+ check and no way to ask what a file *is* before fetching it. Everything here
7
+ is organised around three facts that only show up at scale:
8
+
9
+ * **a `file_reference` expires.** Every path that touches bytes re-fetches its
10
+ source first (`_media.fetch_message`), because the fix for
11
+ `FILE_REFERENCE_EXPIRED` is never a retry.
12
+ * **the server's limits are the server's.** `media limit get` reads
13
+ `help.getAppConfig`; `media upload` refuses an oversized file *before* the
14
+ first part goes out rather than after twenty minutes of upload.
15
+ * **a transfer is a thing with a lifetime.** `--background` hands it to the
16
+ daemon, `media transfer list` shows it, `media transfer stop` cancels it and
17
+ `media transfer retry` re-fetches the message before restarting. That is
18
+ what the GUI's Downloads panel is, and it is client-local state in both.
19
+
20
+ Telethon is imported inside functions, never at module scope (§2.2).
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import contextlib
26
+ import hashlib
27
+ import os
28
+ import time
29
+ from pathlib import Path
30
+ from typing import Annotated, Any, Literal
31
+
32
+ from tlgr.core.errors import (
33
+ EXIT_EMPTY,
34
+ NotFoundError,
35
+ NotSupportedError,
36
+ PermissionError_,
37
+ UsageError,
38
+ )
39
+ from tlgr.core.pagination import PageKind, build_page
40
+ from tlgr.core.timefmt import fmt_dt, parse_dt, to_unix
41
+ from tlgr.models.base import Request
42
+ from tlgr.models.media import (
43
+ AutoDownloadPreset,
44
+ AutoDownloadSaved,
45
+ AutoDownloadSettings,
46
+ AutoSaveException,
47
+ AutoSaveRule,
48
+ AutoSaveSaved,
49
+ AutoSaveSettings,
50
+ ContentSettings,
51
+ ContentSettingsSaved,
52
+ Downloaded,
53
+ FileRef,
54
+ MediaEdited,
55
+ MediaEvent,
56
+ MediaExportResult,
57
+ MediaInfo,
58
+ MediaItem,
59
+ MediaLimits,
60
+ MediaQuality,
61
+ MediaRead,
62
+ MediaSize,
63
+ PaidItem,
64
+ PaidPost,
65
+ StorageCleared,
66
+ StorageUsage,
67
+ Transfer,
68
+ TransferRestarted,
69
+ TransferStopped,
70
+ Uploaded,
71
+ Wallpaper,
72
+ WallpaperInstalled,
73
+ WallpaperRemoved,
74
+ WallpaperSettings,
75
+ WallpaperUploaded,
76
+ )
77
+ from tlgr.models.page import Page
78
+ from tlgr.models.peer import PeerRef
79
+ from tlgr.ops import _media, _send
80
+ from tlgr.ops._params import arg, choice, opt
81
+ from tlgr.ops._serialize import entity_to_peer, message_entities
82
+ from tlgr.ops._spec import OpContext, OperationSpec, Surface
83
+
84
+ __all__ = [name for name in dir() if name.startswith("SPEC_")]
85
+
86
+ #: The naming pattern `--out-dir` fills in.
87
+ DEFAULT_TEMPLATE = "{date}_{id}_{name}"
88
+
89
+ _EXAMPLE_DOWNLOAD: dict[str, Any] = {
90
+ "msg_id": 12345,
91
+ "chat_id": 777123,
92
+ "path": "/home/u/.tlgr/downloads/alice/2026-09-03_12345_cat.jpg",
93
+ "bytes": 184320,
94
+ "kind": "photo",
95
+ "mime": "image/jpeg",
96
+ }
97
+
98
+ _EXAMPLE_ITEM: dict[str, Any] = {
99
+ "msg_id": 12345,
100
+ "chat_id": 777123,
101
+ "date": "2026-09-03T09:14:07Z",
102
+ "date_unix": 1788340447,
103
+ "kind": "photo",
104
+ "name": "cat.jpg",
105
+ "size": 184320,
106
+ "mime": "image/jpeg",
107
+ }
108
+
109
+
110
+ # ---------------------------------------------------------------------------
111
+ # Shared plumbing
112
+ # ---------------------------------------------------------------------------
113
+
114
+
115
+ def _client(ctx: OpContext) -> Any:
116
+ return _media.client(ctx)
117
+
118
+
119
+ def _downloads_root(ctx: OpContext) -> Path:
120
+ """`~/.tlgr/downloads`, from the daemon's own paths rather than `~`.
121
+
122
+ Reading `HOME` here would make a test write into the developer's home
123
+ directory; the daemon knows where its base is and hands it over.
124
+ """
125
+ paths = getattr(ctx, "paths", None)
126
+ if paths is not None and getattr(paths, "downloads", None) is not None:
127
+ return Path(paths.downloads)
128
+ from tlgr.core.config import get_downloads_dir
129
+
130
+ return Path(get_downloads_dir())
131
+
132
+
133
+ def _safe_name(name: str) -> str:
134
+ """A server-supplied file name that cannot escape the output directory.
135
+
136
+ `../../.ssh/authorized_keys` is a legal `DocumentAttributeFilename`, and
137
+ joining one onto `--out-dir` is how a download becomes an overwrite.
138
+ """
139
+ cleaned = os.path.basename((name or "").replace("\\", "/")).strip()
140
+ cleaned = cleaned.lstrip(".") or "file"
141
+ return cleaned[:180]
142
+
143
+
144
+ def _fill_template(template: str, *, date: str, message_id: int, name: str) -> str:
145
+ try:
146
+ return _safe_name(template.format(date=date[:10] or "0000-00-00", id=message_id, name=name))
147
+ except (KeyError, IndexError) as exc:
148
+ raise UsageError(
149
+ f"--name-template: unknown placeholder {exc}; use {{date}}, {{id}}, {{name}}",
150
+ field="name_template",
151
+ ) from exc
152
+
153
+
154
+ def _unique(target: Path) -> Path:
155
+ """`cat.jpg` → `cat (2).jpg` rather than an overwrite."""
156
+ if not target.exists():
157
+ return target
158
+ stem, suffix = target.stem, target.suffix
159
+ for index in range(2, 1000):
160
+ candidate = target.with_name(f"{stem} ({index}){suffix}")
161
+ if not candidate.exists():
162
+ return candidate
163
+ return target
164
+
165
+
166
+ def _size_arg(value: str | None, field: str) -> int | None:
167
+ """`20M`, `512k`, `1073741824` → bytes."""
168
+ if value in (None, ""):
169
+ return None
170
+ text = str(value).strip().lower()
171
+ factor = 1
172
+ for suffix, scale in (("k", 1024), ("m", 1024**2), ("g", 1024**3)):
173
+ if text.endswith(suffix):
174
+ factor, text = scale, text[:-1]
175
+ break
176
+ try:
177
+ return int(float(text) * factor)
178
+ except ValueError as exc:
179
+ raise UsageError(
180
+ f"--{field.replace('_', '-')}: {value!r} is not a size", field=field
181
+ ) from exc
182
+
183
+
184
+ def _byte_range(value: str | None) -> tuple[int, int | None]:
185
+ """`0-1M`, `5M-`, `1024-2047` → `(offset, limit)`."""
186
+ if not value:
187
+ return 0, None
188
+ head, _, tail = str(value).partition("-")
189
+ start = _size_arg(head or "0", "range") or 0
190
+ end = _size_arg(tail, "range") if tail else None
191
+ return start, (end - start + 1) if end is not None and end >= start else None
192
+
193
+
194
+ def _elapsed(started: float) -> float:
195
+ return round(time.monotonic() - started, 3)
196
+
197
+
198
+ def _is_premium(ctx: OpContext) -> bool:
199
+ """Whether this account is Premium, which doubles most upload limits."""
200
+ session = getattr(ctx, "session", None)
201
+ return bool(getattr(getattr(session, "me", None), "premium", False))
202
+
203
+
204
+ def _transfers(ctx: OpContext) -> Any:
205
+ store = getattr(ctx, "transfers", None)
206
+ if store is None:
207
+ raise NotSupportedError(
208
+ "this build has no transfer store; transfers live in the daemon and "
209
+ "this operation was not reached through it"
210
+ )
211
+ return store
212
+
213
+
214
+ async def _download_bytes(
215
+ ctx: OpContext,
216
+ location: Any,
217
+ target: Path,
218
+ *,
219
+ size: int = 0,
220
+ dc_id: int = 0,
221
+ offset: int = 0,
222
+ limit: int | None = None,
223
+ resume: bool = True,
224
+ part_size: int = 512 * 1024,
225
+ connections: int = 1,
226
+ refresh: Any = None,
227
+ progress: Any = None,
228
+ ) -> Path:
229
+ """The daemon's download pipeline, reached the way `upload_file` is.
230
+
231
+ `ops/` may not import `daemon/` (§2.2), so the pipeline is a service on
232
+ the context — which is also what makes an operation testable without a
233
+ socket.
234
+ """
235
+ download = getattr(ctx, "download_file", None)
236
+ if download is None: # pragma: no cover - the daemon always supplies one
237
+ raise UsageError("this context cannot download files")
238
+ written: Any = await download(
239
+ location,
240
+ target,
241
+ size=size,
242
+ dc_id=dc_id,
243
+ offset=offset,
244
+ limit=limit,
245
+ resume=resume,
246
+ part_size=part_size,
247
+ connections=connections,
248
+ refresh=refresh,
249
+ progress=progress,
250
+ )
251
+ return Path(written)
252
+
253
+
254
+ def _sha256(path: Path) -> str:
255
+ digest = hashlib.sha256()
256
+ with open(path, "rb") as handle:
257
+ for chunk in iter(lambda: handle.read(1024 * 1024), b""):
258
+ digest.update(chunk)
259
+ return digest.hexdigest()
260
+
261
+
262
+ # ---------------------------------------------------------------------------
263
+ # media get
264
+ # ---------------------------------------------------------------------------
265
+
266
+
267
+ class GetReq(Request):
268
+ chat: Annotated[PeerRef, arg(0, metavar="CHAT", kind="peer", help="Chat holding the media.")]
269
+ msg_id: Annotated[int, arg(1, metavar="MSG_ID", kind="msg_id", help="Message id.")]
270
+ sizes: Annotated[bool, opt("--sizes", help="List every size/thumbnail variant.")] = False
271
+ qualities: Annotated[
272
+ bool, opt("--qualities", help="List the alt_documents video transcodes.")
273
+ ] = False
274
+ stickers: Annotated[
275
+ bool, opt("--stickers", help="List sticker sets baked into the photo/video.")
276
+ ] = False
277
+ ads: Annotated[
278
+ bool, opt("--ads", help="List sponsored inserts (listed only, never viewed).")
279
+ ] = False
280
+ refresh: Annotated[
281
+ bool, opt("--refresh", help="Re-fetch the message so ids and paid state are current.")
282
+ ] = True
283
+ album: Annotated[bool, opt("--album", help="Report every sibling sharing grouped_id.")] = False
284
+
285
+
286
+ def _media_info(message: Any, *, chat_id: int, sizes: bool, qualities: bool) -> MediaInfo:
287
+ """The one function that turns a message's media into `MediaInfo`."""
288
+ media = getattr(message, "media", None)
289
+ document = _media.document_of(media)
290
+ photo = _media.photo_of(media) if document is None else None
291
+ facts = _media.attributes_of(document) if document is not None else {}
292
+ date, date_unix = _media.message_dates(message)
293
+
294
+ from tlgr.ops._serialize import media_summary
295
+
296
+ summary = media_summary(media)
297
+ info = MediaInfo(
298
+ chat_id=chat_id,
299
+ msg_id=int(getattr(message, "id", 0) or 0),
300
+ kind=summary.kind if summary is not None else "unsupported",
301
+ tl_type=type(media).__name__ if media is not None else "",
302
+ grouped_id=getattr(message, "grouped_id", None),
303
+ mime=getattr(document, "mime_type", None),
304
+ size=getattr(document, "size", None),
305
+ file_name=facts.get("file_name"),
306
+ width=facts.get("width"),
307
+ height=facts.get("height"),
308
+ duration=int(facts["duration"]) if facts.get("duration") is not None else None,
309
+ supports_streaming=bool(facts.get("supports_streaming")),
310
+ nosound=bool(facts.get("nosound")),
311
+ round=bool(facts.get("round")),
312
+ voice=bool(facts.get("voice")),
313
+ waveform=bool(facts.get("waveform")),
314
+ title=facts.get("title"),
315
+ performer=facts.get("performer"),
316
+ sticker=facts.get("sticker"),
317
+ custom_emoji=facts.get("custom_emoji"),
318
+ spoiler=bool(getattr(media, "spoiler", False)),
319
+ ttl_seconds=getattr(media, "ttl_seconds", None),
320
+ video_cover=getattr(getattr(media, "video_cover", None), "id", None),
321
+ video_timestamp=getattr(media, "video_timestamp", None),
322
+ has_stickers=bool(getattr(document, "has_stickers", False))
323
+ or bool(getattr(photo, "has_stickers", False)),
324
+ protected=bool(getattr(message, "noforwards", False)),
325
+ paid=getattr(media, "stars_amount", None),
326
+ dc_id=getattr(document or photo, "dc_id", None),
327
+ doc_id=getattr(document or photo, "id", None),
328
+ access_hash=getattr(document or photo, "access_hash", None),
329
+ file_reference_b64=_media.b64(getattr(document or photo, "file_reference", None)),
330
+ file_id=_media.file_id_of(media),
331
+ date=date,
332
+ date_unix=date_unix,
333
+ caption=getattr(message, "message", "") or "",
334
+ entities=message_entities(message),
335
+ )
336
+ if sizes:
337
+ if photo is not None:
338
+ info.thumbs = _media.photo_sizes(photo)
339
+ else:
340
+ info.thumbs = [
341
+ MediaSize(
342
+ type=str(getattr(size, "type", "") or ""),
343
+ width=getattr(size, "w", None),
344
+ height=getattr(size, "h", None),
345
+ size=getattr(size, "size", None),
346
+ bytes_b64=_media.b64(getattr(size, "bytes", None)),
347
+ )
348
+ for size in (getattr(document, "thumbs", None) or [])
349
+ ]
350
+ if qualities:
351
+ info.qualities = [
352
+ MediaQuality(
353
+ doc_id=int(getattr(alt, "id", 0) or 0),
354
+ mime=getattr(alt, "mime_type", None),
355
+ size=getattr(alt, "size", None),
356
+ width=_media.attributes_of(alt).get("width"),
357
+ height=_media.attributes_of(alt).get("height"),
358
+ name=_media.attributes_of(alt).get("file_name"),
359
+ )
360
+ for alt in (getattr(media, "alt_documents", None) or [])
361
+ ]
362
+ return info
363
+
364
+
365
+ async def get(ctx: OpContext, req: GetReq) -> MediaInfo:
366
+ """Everything the media declares, without downloading a byte."""
367
+ peer = await _send.resolve(ctx, req.chat)
368
+ chat_id = _send.peer_id_of(peer)
369
+ message = await _media.fetch_message(ctx, peer, req.msg_id)
370
+ if getattr(message, "media", None) is None:
371
+ raise NotFoundError(f"message {req.msg_id} carries no media")
372
+
373
+ info = _media_info(message, chat_id=chat_id, sizes=req.sizes, qualities=req.qualities)
374
+ if req.stickers:
375
+ info.attached_sets = await _attached_sets(ctx, message)
376
+ if req.ads:
377
+ info.ads = await _video_ads(ctx, peer, req.msg_id)
378
+ if req.album and info.grouped_id is not None:
379
+ info.album = await _album_siblings(ctx, peer, chat_id, message)
380
+ return info
381
+
382
+
383
+ async def _attached_sets(ctx: OpContext, message: Any) -> list[str]:
384
+ """`messages.getAttachedStickers` — sets composited into the image."""
385
+ from telethon.tl import types
386
+ from telethon.tl.functions import messages as fn
387
+
388
+ media = getattr(message, "media", None)
389
+ document = _media.document_of(media)
390
+ photo = _media.photo_of(media) if document is None else None
391
+ if document is not None:
392
+ stickered: Any = types.InputStickeredMediaDocument(id=_media.input_document(document))
393
+ elif photo is not None:
394
+ stickered = types.InputStickeredMediaPhoto(id=_media.input_photo(photo))
395
+ else:
396
+ return []
397
+ result = await _client(ctx)(fn.GetAttachedStickersRequest(media=stickered))
398
+ names: list[str] = []
399
+ for covered in result or []:
400
+ inner = getattr(covered, "set", covered)
401
+ name = getattr(inner, "short_name", None)
402
+ if name:
403
+ names.append(str(name))
404
+ return names
405
+
406
+
407
+ async def _video_ads(ctx: OpContext, peer: Any, message_id: int) -> list[dict[str, Any]]:
408
+ """Sponsored inserts, listed and never viewed.
409
+
410
+ `viewSponsoredMessage`/`clickSponsoredMessage` are deliberately absent:
411
+ calling either would inflate an advertiser's metrics on the user's behalf.
412
+ """
413
+ from telethon.tl.functions import messages as fn
414
+
415
+ result = await _client(ctx)(fn.GetSponsoredMessagesRequest(peer=peer, msg_id=message_id))
416
+ out: list[dict[str, Any]] = []
417
+ for item in getattr(result, "messages", None) or []:
418
+ out.append(
419
+ {
420
+ "title": getattr(item, "title", None),
421
+ "message": getattr(item, "message", None),
422
+ "url": getattr(item, "url", None),
423
+ "sponsor": getattr(item, "sponsor_info", None),
424
+ }
425
+ )
426
+ return out
427
+
428
+
429
+ async def _album_siblings(ctx: OpContext, peer: Any, chat_id: int, message: Any) -> list[MediaInfo]:
430
+ """The other messages of this media group.
431
+
432
+ Album membership is invisible without `grouped_id`, and the ids are not
433
+ contiguous in general — so the window around the message is read and
434
+ filtered rather than guessed.
435
+ """
436
+ grouped = getattr(message, "grouped_id", None)
437
+ if grouped is None:
438
+ return []
439
+ message_id = int(getattr(message, "id", 0))
440
+ ids = [i for i in range(max(1, message_id - 10), message_id + 11) if i != message_id]
441
+ found = await _client(ctx).get_messages(peer, ids=ids)
442
+ return [
443
+ _media_info(sibling, chat_id=chat_id, sizes=False, qualities=False)
444
+ for sibling in (found or [])
445
+ if sibling is not None and getattr(sibling, "grouped_id", None) == grouped
446
+ ]
447
+
448
+
449
+ SPEC_GET = OperationSpec(
450
+ id="media.get",
451
+ request=GetReq,
452
+ response=MediaInfo,
453
+ impl=get,
454
+ summary="Everything a message's media declares, without downloading a byte",
455
+ description=(
456
+ "The JSON contract the whole group leans on: kind, mime, dimensions, "
457
+ "duration, album membership, ids and access hash, protection and paid "
458
+ "state. --sizes lists every thumbnail variant (the stripped and vector "
459
+ "ones cost no request at all), --qualities the alt_documents "
460
+ "transcodes, --stickers the sets baked into the image, --ads the "
461
+ "sponsored inserts (listed, never viewed)."
462
+ ),
463
+ aliases=("media.info",),
464
+ columns=("kind", "mime", "size", "file_name"),
465
+ headers=("Kind", "MIME", "Size", "Name"),
466
+ empty_exit=EXIT_EMPTY,
467
+ example={
468
+ "chat_id": 777123,
469
+ "msg_id": 12345,
470
+ "kind": "video",
471
+ "mime": "video/mp4",
472
+ "size": 8412300,
473
+ "width": 1280,
474
+ "height": 720,
475
+ "duration": 42,
476
+ "supports_streaming": True,
477
+ },
478
+ example_args="media get @alice 12345",
479
+ covers=(
480
+ "media.attached-stickers",
481
+ "media.dc-routing",
482
+ "media.info",
483
+ "media.stripped-vector-thumbnails",
484
+ "media.video-message-ads",
485
+ "media.video-quality-select",
486
+ ),
487
+ covers_partial=("media.file-id-export-import", "media.paid-media-inspect"),
488
+ coverage_note=(
489
+ "file_id is reported here; refreshing an expired one is `media file-id get`. "
490
+ "Paid posts are listed and refreshed by `media paid list`."
491
+ ),
492
+ tags=frozenset({"read-only"}),
493
+ )
494
+
495
+
496
+ # ---------------------------------------------------------------------------
497
+ # media list
498
+ # ---------------------------------------------------------------------------
499
+
500
+
501
+ class ListReq(Request):
502
+ chat: Annotated[PeerRef, arg(0, metavar="CHAT", kind="peer", help="Chat to read.")]
503
+ type: Annotated[
504
+ str,
505
+ choice(*_media.MEDIA_FILTERS, help="Which shared-media tab."),
506
+ ] = "media"
507
+ query: Annotated[
508
+ str | None,
509
+ opt("-q", "--query", metavar="TEXT", help="Server-side search over name and caption."),
510
+ ] = None
511
+ since: Annotated[
512
+ str | None, opt("--since", metavar="TS", kind="datetime", help="Only at/after this time.")
513
+ ] = None
514
+ until: Annotated[
515
+ str | None, opt("--until", metavar="TS", kind="datetime", help="Only before this time.")
516
+ ] = None
517
+ topic: Annotated[
518
+ int | None, opt("--topic", metavar="ID", kind="msg_id", help="Restrict to a forum topic.")
519
+ ] = None
520
+ counts: Annotated[bool, opt("--counts", help="Per-type counters instead of items.")] = False
521
+ calendar: Annotated[
522
+ bool, opt("--calendar", help="Per-day counts and jump ids instead of items.")
523
+ ] = False
524
+ month: Annotated[
525
+ str | None, opt("--month", metavar="YYYY-MM", help="Month for --calendar.")
526
+ ] = None
527
+ ids_only: Annotated[
528
+ bool, opt("--ids-only", help="Bare message ids, to pipe into another command.")
529
+ ] = False
530
+ positions: Annotated[bool, opt("--positions", help="Sparse scrollbar positions.")] = False
531
+
532
+
533
+ def _item_from_message(message: Any, *, chat_id: int, chat: Any = None) -> MediaItem:
534
+ media = getattr(message, "media", None)
535
+ document = _media.document_of(media)
536
+ facts = _media.attributes_of(document) if document is not None else {}
537
+ date, date_unix = _media.message_dates(message)
538
+ from tlgr.ops._serialize import media_summary
539
+
540
+ summary = media_summary(media)
541
+ return MediaItem(
542
+ msg_id=int(getattr(message, "id", 0) or 0),
543
+ chat_id=chat_id,
544
+ date=date,
545
+ date_unix=date_unix,
546
+ kind=summary.kind if summary is not None else None,
547
+ name=facts.get("file_name") or facts.get("title"),
548
+ size=getattr(document, "size", None),
549
+ duration=int(facts["duration"]) if facts.get("duration") is not None else None,
550
+ mime=getattr(document, "mime_type", None),
551
+ from_id=getattr(message, "sender_id", None),
552
+ grouped_id=getattr(message, "grouped_id", None),
553
+ file_id=_media.file_id_of(media),
554
+ chat=chat,
555
+ )
556
+
557
+
558
+ async def list_media(ctx: OpContext, req: ListReq) -> Page[MediaItem]:
559
+ """One shared-media tab of a chat, one signed page at a time."""
560
+ limit, state = _media.window(ctx, "media.list", PageKind.SEARCH)
561
+ peer = await _send.resolve(ctx, req.chat)
562
+ chat_id = _send.peer_id_of(peer)
563
+
564
+ if req.counts:
565
+ return await _search_counters(ctx, peer, req)
566
+ if req.calendar:
567
+ return await _search_calendar(ctx, peer, req, limit)
568
+ if req.positions:
569
+ return await _search_positions(ctx, peer, req, chat_id, limit)
570
+
571
+ kwargs: dict[str, Any] = {
572
+ "filter": _media.media_filter(req.type),
573
+ "offset_id": int(state.get("offset_id", 0)),
574
+ "limit": limit,
575
+ }
576
+ if req.query:
577
+ kwargs["search"] = req.query
578
+ if req.topic is not None:
579
+ kwargs["reply_to"] = req.topic
580
+ if req.until:
581
+ kwargs["offset_date"] = parse_dt(req.until)
582
+
583
+ items: list[MediaItem] = []
584
+ floor = to_unix(parse_dt(req.since)) if req.since else None
585
+ async for message in _client(ctx).iter_messages(peer, **kwargs):
586
+ if message is None:
587
+ continue
588
+ item = _item_from_message(message, chat_id=chat_id)
589
+ if floor is not None and item.date_unix < floor:
590
+ continue
591
+ if req.ids_only:
592
+ item = MediaItem(msg_id=item.msg_id, chat_id=chat_id)
593
+ items.append(item)
594
+
595
+ next_state = {"offset_id": items[-1].msg_id} if items else {}
596
+ return build_page(
597
+ items,
598
+ op="media.list",
599
+ kind=PageKind.SEARCH,
600
+ state=next_state,
601
+ account=ctx.account,
602
+ limit=limit,
603
+ has_more=None if items else False,
604
+ )
605
+
606
+
607
+ async def _search_counters(ctx: OpContext, peer: Any, req: ListReq) -> Page[MediaItem]:
608
+ """`messages.getSearchCounters` — the numbers above the media tabs."""
609
+ from telethon.tl.functions import messages as fn
610
+
611
+ names = [name for name in _media.MEDIA_FILTERS if name != "all"]
612
+ result = await _client(ctx)(
613
+ fn.GetSearchCountersRequest(
614
+ peer=peer,
615
+ filters=[_media.media_filter(name) for name in names],
616
+ top_msg_id=req.topic,
617
+ )
618
+ )
619
+ items = [
620
+ MediaItem(type=name, count=int(getattr(entry, "count", 0) or 0))
621
+ for name, entry in zip(names, result or [], strict=False)
622
+ ]
623
+ return Page(items=items, has_more=False, total=len(items))
624
+
625
+
626
+ async def _search_calendar(ctx: OpContext, peer: Any, req: ListReq, limit: int) -> Page[MediaItem]:
627
+ """`messages.getSearchResultsCalendar` — per-day counts and jump ids."""
628
+ from telethon.tl.functions import messages as fn
629
+
630
+ offset_date = parse_dt(f"{req.month}-01") if req.month else None
631
+ result = await _client(ctx)(
632
+ fn.GetSearchResultsCalendarRequest(
633
+ peer=peer,
634
+ filter=_media.media_filter(req.type),
635
+ offset_id=0,
636
+ offset_date=offset_date,
637
+ )
638
+ )
639
+ items: list[MediaItem] = []
640
+ for period in getattr(result, "periods", None) or []:
641
+ stamp = fmt_dt(getattr(period, "date", None)) or ""
642
+ items.append(
643
+ MediaItem(
644
+ msg_id=int(getattr(period, "min_msg_id", 0) or 0),
645
+ date=stamp,
646
+ date_unix=to_unix(getattr(period, "date", None)) or 0,
647
+ period=stamp[:10],
648
+ count=int(getattr(period, "count", 0) or 0),
649
+ )
650
+ )
651
+ return Page(items=items[:limit], has_more=False, total=int(getattr(result, "count", 0) or 0))
652
+
653
+
654
+ async def _search_positions(
655
+ ctx: OpContext, peer: Any, req: ListReq, chat_id: int, limit: int
656
+ ) -> Page[MediaItem]:
657
+ from telethon.tl.functions import messages as fn
658
+
659
+ result = await _client(ctx)(
660
+ fn.GetSearchResultsPositionsRequest(
661
+ peer=peer, filter=_media.media_filter(req.type), offset_id=0, limit=limit
662
+ )
663
+ )
664
+ items = [
665
+ MediaItem(
666
+ msg_id=int(getattr(position, "msg_id", 0) or 0),
667
+ chat_id=chat_id,
668
+ count=int(getattr(position, "offset", 0) or 0),
669
+ date=fmt_dt(getattr(position, "date", None)) or "",
670
+ )
671
+ for position in getattr(result, "positions", None) or []
672
+ ]
673
+ return Page(items=items, has_more=False, total=int(getattr(result, "count", 0) or 0))
674
+
675
+
676
+ SPEC_LIST = OperationSpec(
677
+ id="media.list",
678
+ request=ListReq,
679
+ response=Page[MediaItem],
680
+ impl=list_media,
681
+ summary="Shared media of a chat, one media type at a time",
682
+ description=(
683
+ "--type maps one-to-one onto Telegram's own tabs, including "
684
+ "`chat-photo`, which is a group or channel's avatar history. --counts "
685
+ "returns the per-tab counters, --calendar the per-day counts with a "
686
+ "jump id, --ids-only bare ids so the result can drive `message "
687
+ "forward`, `message delete` or `media download`."
688
+ ),
689
+ paginated=PageKind.SEARCH,
690
+ columns=("msg_id", "date", "kind", "name", "size"),
691
+ headers=("ID", "Date", "Kind", "Name", "Size"),
692
+ example={"items": [_EXAMPLE_ITEM], "has_more": False},
693
+ example_args="media list @alice --type photo",
694
+ covers=(
695
+ "chat.photo-history",
696
+ "media.music-player-queue",
697
+ "media.saved-messages-drive",
698
+ "media.shared-media-calendar",
699
+ "media.shared-media-counters",
700
+ "media.shared-media-list",
701
+ "media.shared-media-search",
702
+ ),
703
+ covers_partial=("media.shared-media-bulk-actions",),
704
+ coverage_note="--ids-only feeds the bulk verbs; `media export` owns the bulk download.",
705
+ )
706
+
707
+
708
+ # ---------------------------------------------------------------------------
709
+ # media search
710
+ # ---------------------------------------------------------------------------
711
+
712
+
713
+ class SearchReq(Request):
714
+ query: Annotated[str, arg(0, metavar="QUERY", required=False, help="Text to find.")] = ""
715
+ type: Annotated[
716
+ str,
717
+ choice(
718
+ "photo",
719
+ "video",
720
+ "media",
721
+ "file",
722
+ "link",
723
+ "music",
724
+ "voice",
725
+ "gif",
726
+ help="Content-type tab.",
727
+ ),
728
+ ] = "media"
729
+ since: Annotated[
730
+ str | None, opt("--since", metavar="TS", kind="datetime", help="Only at/after this time.")
731
+ ] = None
732
+ until: Annotated[
733
+ str | None, opt("--until", metavar="TS", kind="datetime", help="Only before this time.")
734
+ ] = None
735
+ sent: Annotated[
736
+ bool, opt("--sent", help="Only your own recently-sent media (one capped page).")
737
+ ] = False
738
+ source: Annotated[
739
+ str,
740
+ choice("any", "saved", "chats", "inline", help="Music picker source."),
741
+ ] = "any"
742
+ bot: Annotated[
743
+ PeerRef | None,
744
+ opt("--bot", metavar="USER", kind="user", help="Inline bot for --source inline."),
745
+ ] = None
746
+ folder: Annotated[
747
+ int | None, opt("--folder", metavar="ID", help="Restrict to a chat folder.")
748
+ ] = None
749
+ broadcasts_only: Annotated[bool, opt("--broadcasts-only", help="Only channels.")] = False
750
+
751
+
752
+ async def search(ctx: OpContext, req: SearchReq) -> Page[MediaItem]:
753
+ """Search media across every chat — the GUI's global media tabs."""
754
+ limit, state = _media.window(ctx, "media.search", PageKind.SEARCH)
755
+ from telethon.tl import types
756
+ from telethon.tl.functions import messages as fn
757
+
758
+ if req.sent:
759
+ return await _search_sent(ctx, req, limit)
760
+ if req.source == "saved":
761
+ return await _saved_music(ctx, limit)
762
+ if req.source == "inline":
763
+ return await _inline_music(ctx, req, limit)
764
+
765
+ offset_peer: Any = types.InputPeerEmpty()
766
+ if state.get("offset_peer"):
767
+ from tlgr.models.peer import parse_peer_ref
768
+
769
+ with contextlib.suppress(Exception):
770
+ offset_peer = await _send.resolve(ctx, parse_peer_ref(str(state["offset_peer"])))
771
+
772
+ result = await _client(ctx)(
773
+ fn.SearchGlobalRequest(
774
+ q=req.query or "",
775
+ filter=_media.media_filter(req.type),
776
+ min_date=parse_dt(req.since) if req.since else None,
777
+ max_date=parse_dt(req.until) if req.until else None,
778
+ offset_rate=int(state.get("offset_rate", 0)),
779
+ offset_peer=offset_peer,
780
+ offset_id=int(state.get("offset_id", 0)),
781
+ limit=limit,
782
+ broadcasts_only=req.broadcasts_only or None,
783
+ folder_id=req.folder,
784
+ )
785
+ )
786
+ peers = {
787
+ entity.id: entity_to_peer(entity)
788
+ for entity in [
789
+ *(getattr(result, "users", None) or []),
790
+ *(getattr(result, "chats", None) or []),
791
+ ]
792
+ }
793
+ items: list[MediaItem] = []
794
+ last: Any = None
795
+ for message in getattr(result, "messages", None) or []:
796
+ from tlgr.ops._serialize import peer_id_of as marked_of
797
+
798
+ chat_id = marked_of(getattr(message, "peer_id", None)) or 0
799
+ raw_id = abs(chat_id) if chat_id > 0 else abs(chat_id) % 1000000000000
800
+ items.append(_item_from_message(message, chat_id=chat_id, chat=peers.get(raw_id)))
801
+ last = message
802
+
803
+ # searchGlobal paginates on the *triple*; an offset_id alone silently loops.
804
+ next_state: dict[str, Any] = {}
805
+ if last is not None:
806
+ next_state = {
807
+ "offset_rate": int(getattr(result, "next_rate", 0) or 0),
808
+ "offset_id": int(getattr(last, "id", 0) or 0),
809
+ "offset_peer": items[-1].chat_id,
810
+ }
811
+ return build_page(
812
+ items,
813
+ op="media.search",
814
+ kind=PageKind.SEARCH,
815
+ state=next_state,
816
+ account=ctx.account,
817
+ limit=limit,
818
+ total=getattr(result, "count", None),
819
+ )
820
+
821
+
822
+ async def _search_sent(ctx: OpContext, req: SearchReq, limit: int) -> Page[MediaItem]:
823
+ """`messages.searchSentMedia` — the attach dialog's "Recent files".
824
+
825
+ One capped page by design: the endpoint takes no offset at all, so a
826
+ cursor would be a promise the server cannot keep.
827
+ """
828
+ from telethon.tl.functions import messages as fn
829
+
830
+ result = await _client(ctx)(
831
+ fn.SearchSentMediaRequest(
832
+ q=req.query or "", filter=_media.media_filter(req.type), limit=limit
833
+ )
834
+ )
835
+ from tlgr.ops._serialize import peer_id_of as marked_of
836
+
837
+ items = [
838
+ _item_from_message(message, chat_id=marked_of(getattr(message, "peer_id", None)) or 0)
839
+ for message in (getattr(result, "messages", None) or [])
840
+ ]
841
+ return Page(items=items, has_more=False, total=len(items))
842
+
843
+
844
+ async def _saved_music(ctx: OpContext, limit: int) -> Page[MediaItem]:
845
+ """`users.getSavedMusic` — the tracks saved on this profile."""
846
+ from telethon.tl import types
847
+ from telethon.tl.functions import users as fn
848
+
849
+ result = await _client(ctx)(
850
+ fn.GetSavedMusicRequest(id=types.InputUserSelf(), offset=0, limit=limit, hash=0)
851
+ )
852
+ items: list[MediaItem] = []
853
+ for document in getattr(result, "documents", None) or []:
854
+ facts = _media.attributes_of(document)
855
+ items.append(
856
+ MediaItem(
857
+ kind="audio",
858
+ name=facts.get("title") or facts.get("file_name"),
859
+ size=getattr(document, "size", None),
860
+ duration=int(facts["duration"]) if facts.get("duration") is not None else None,
861
+ mime=getattr(document, "mime_type", None),
862
+ file_id=_media.file_id_of(document),
863
+ )
864
+ )
865
+ return Page(items=items, has_more=False, total=len(items))
866
+
867
+
868
+ async def _inline_music(ctx: OpContext, req: SearchReq, limit: int) -> Page[MediaItem]:
869
+ """The inline half of the music picker."""
870
+ from telethon.tl import types
871
+ from telethon.tl.functions import messages as fn
872
+
873
+ if req.bot is None:
874
+ raise UsageError("--source inline needs --bot", field="bot")
875
+ bot = await _send.resolve(ctx, req.bot)
876
+ result = await _client(ctx)(
877
+ fn.GetInlineBotResultsRequest(
878
+ bot=bot, peer=types.InputPeerEmpty(), query=req.query or "", offset=""
879
+ )
880
+ )
881
+ items: list[MediaItem] = []
882
+ for entry in (getattr(result, "results", None) or [])[:limit]:
883
+ document = getattr(entry, "document", None)
884
+ facts = _media.attributes_of(document) if document is not None else {}
885
+ items.append(
886
+ MediaItem(
887
+ kind="audio",
888
+ name=getattr(entry, "title", None) or facts.get("file_name"),
889
+ mime=getattr(document, "mime_type", None),
890
+ duration=int(facts["duration"]) if facts.get("duration") is not None else None,
891
+ file_id=_media.file_id_of(document) if document is not None else None,
892
+ )
893
+ )
894
+ return Page(items=items, has_more=False, total=len(items))
895
+
896
+
897
+ SPEC_SEARCH = OperationSpec(
898
+ id="media.search",
899
+ request=SearchReq,
900
+ response=Page[MediaItem],
901
+ impl=search,
902
+ summary="Search media across every chat",
903
+ description=(
904
+ "`messages.searchGlobal` paginates on the triple (offset_rate, "
905
+ "offset_peer, offset_id) and a plain offset_id silently loops, so the "
906
+ "cursor carries all three. An empty query with a --type is legal and "
907
+ "is how the GUI fills its tabs. --sent is the attach dialog's recent "
908
+ "files: one capped page, because the endpoint takes no offset."
909
+ ),
910
+ paginated=PageKind.SEARCH,
911
+ columns=("msg_id", "chat_id", "date", "kind", "name"),
912
+ headers=("ID", "Chat", "Date", "Kind", "Name"),
913
+ example={"items": [_EXAMPLE_ITEM], "has_more": False},
914
+ example_args="media search invoice --type file",
915
+ covers=(
916
+ "media.attach-music-picker",
917
+ "media.global-media-search",
918
+ "media.recent-sent-media-search",
919
+ ),
920
+ )
921
+
922
+
923
+ # ---------------------------------------------------------------------------
924
+ # media download
925
+ # ---------------------------------------------------------------------------
926
+
927
+
928
+ class DownloadReq(Request):
929
+ chat: Annotated[
930
+ PeerRef | None,
931
+ arg(0, metavar="CHAT", required=False, kind="peer", help="Chat holding the media."),
932
+ ] = None
933
+ msg_id: Annotated[
934
+ list[str],
935
+ arg(1, metavar="MSG_ID", required=False, variadic=True, help="Message ids, or `-`."),
936
+ ] = []
937
+ out: Annotated[
938
+ str | None, opt("--out", metavar="PATH", kind="path", help="Write to this exact file.")
939
+ ] = None
940
+ out_dir: Annotated[
941
+ str | None, opt("--out-dir", metavar="DIR", kind="path", help="Write into this directory.")
942
+ ] = None
943
+ stdout: Annotated[
944
+ bool, opt("--stdout", help="Spool the bytes and report the path (see the note).")
945
+ ] = False
946
+ play: Annotated[
947
+ str | None, opt("--play", metavar="CMD", help="Refused: the daemon spawns no players.")
948
+ ] = None
949
+ name_template: Annotated[
950
+ str, opt("--name-template", metavar="PATTERN", help="Naming pattern for --out-dir.")
951
+ ] = DEFAULT_TEMPLATE
952
+ skip_existing: Annotated[
953
+ bool, opt("--skip-existing", help="Skip when the target exists at the right size.")
954
+ ] = False
955
+ overwrite: Annotated[
956
+ bool, opt("--overwrite", help="Overwrite instead of uniquifying the name.")
957
+ ] = False
958
+ album: Annotated[bool, opt("--album", help="Also fetch the message's album siblings.")] = False
959
+ every: Annotated[
960
+ bool, opt("--all", help="Every item in the chat matching --type/--from/--since.")
961
+ ] = False
962
+ max_items: Annotated[
963
+ int,
964
+ opt("--max", metavar="N", help="Cap the items fetched with --all.", ge=1, le=10000),
965
+ ] = 100
966
+ type: Annotated[str, choice(*_media.MEDIA_FILTERS, help="Media filter for --all.")] = "all"
967
+ from_user: Annotated[
968
+ PeerRef | None,
969
+ opt("--from", metavar="USER", kind="user", help="Only media sent by this user."),
970
+ ] = None
971
+ since: Annotated[
972
+ str | None, opt("--since", metavar="TS", kind="datetime", help="Only after this time.")
973
+ ] = None
974
+ until: Annotated[
975
+ str | None, opt("--until", metavar="TS", kind="datetime", help="Only before this time.")
976
+ ] = None
977
+ thumb: Annotated[
978
+ str | None,
979
+ opt("--thumb", metavar="SIZE", help="Fetch a thumbnail: stripped, vector, or a type."),
980
+ ] = None
981
+ quality: Annotated[
982
+ str | None, opt("--quality", metavar="Q", help="Pick an alt_documents transcode.")
983
+ ] = None
984
+ range: Annotated[
985
+ str | None, opt("--range", metavar="START-END", help="Byte range (`0-1M`, `5M-`).")
986
+ ] = None
987
+ resume: Annotated[bool, opt("--resume", help="Continue from the .part sidecar.")] = True
988
+ verify: Annotated[
989
+ bool, opt("--verify", help="Check the bytes against upload.getFileHashes.")
990
+ ] = False
991
+ connections: Annotated[
992
+ int, opt("--connections", metavar="N", help="Parallel ranged readers.", ge=1, le=8)
993
+ ] = 1
994
+ part_size: Annotated[
995
+ int, opt("--part-size", metavar="KB", help="Request size in KB.", ge=4, le=512)
996
+ ] = 512
997
+ background: Annotated[
998
+ bool, opt("--background", help="Hand the transfer to the daemon and print a job id.")
999
+ ] = False
1000
+ read: Annotated[
1001
+ bool, opt("--read", help="Mark the media consumed afterwards (irreversible).")
1002
+ ] = False
1003
+ profile: Annotated[
1004
+ PeerRef | None,
1005
+ opt("--profile", metavar="PEER", kind="peer", help="Download a peer's avatar."),
1006
+ ] = None
1007
+ small: Annotated[bool, opt("--small", help="Small avatar variant.")] = False
1008
+ story: Annotated[
1009
+ str | None, opt("--story", metavar="PEER:ID", help="Download a story's media.")
1010
+ ] = None
1011
+ file_id: Annotated[
1012
+ str | None, opt("--file-id", metavar="ID", help="Download by portable file id.")
1013
+ ] = None
1014
+ web: Annotated[
1015
+ str | None, opt("--web", metavar="URL", help="Fetch a web document through Telegram.")
1016
+ ] = None
1017
+ map: Annotated[
1018
+ str | None, opt("--map", metavar="LAT,LON", help="Fetch a static map preview.")
1019
+ ] = None
1020
+ zoom: Annotated[int, opt("--zoom", metavar="N", help="Map zoom.", ge=1, le=20)] = 15
1021
+ size: Annotated[str, opt("--size", metavar="WxH", help="Map/thumb size.")] = "600x400"
1022
+ allow_protected: Annotated[
1023
+ bool,
1024
+ opt(
1025
+ "--allow-protected",
1026
+ help="Download content the chat marks as protected (noforwards).",
1027
+ ),
1028
+ ] = False
1029
+ no_cdn: Annotated[bool, opt("--no-cdn", help="Debug: read from the master DC.")] = False
1030
+
1031
+
1032
+ def _ids_from(values: list[str]) -> list[int]:
1033
+ """`-` reads ids from stdin, one per line or as a JSON page."""
1034
+ import json
1035
+ import sys
1036
+
1037
+ out: list[int] = []
1038
+ for value in values:
1039
+ text = str(value).strip()
1040
+ if text != "-":
1041
+ try:
1042
+ out.append(int(text))
1043
+ except ValueError as exc:
1044
+ raise UsageError(f"{text!r} is not a message id", field="msg_id") from exc
1045
+ continue
1046
+ if sys.stdin is None or sys.stdin.isatty():
1047
+ raise UsageError("'-' was given but stdin is a terminal", field="msg_id")
1048
+ raw = sys.stdin.read().strip()
1049
+ if raw.startswith(("{", "[")):
1050
+ data = json.loads(raw)
1051
+ rows = data.get("items", data) if isinstance(data, dict) else data
1052
+ out.extend(int(row["msg_id"] if isinstance(row, dict) else row) for row in rows)
1053
+ else:
1054
+ out.extend(int(line) for line in raw.split() if line.strip())
1055
+ return out
1056
+
1057
+
1058
+ async def download(ctx: OpContext, req: DownloadReq) -> Page[Downloaded]:
1059
+ """Fetch bytes: message media, an avatar, a story, a file id or a web file."""
1060
+ if req.play:
1061
+ raise NotSupportedError(
1062
+ "--play is refused: the transfer runs inside the daemon, which does not "
1063
+ "spawn processes on your behalf. Download with --out and pipe the file."
1064
+ )
1065
+ if req.background:
1066
+ return await _background_download(ctx, req)
1067
+
1068
+ started = time.monotonic()
1069
+ if req.profile is not None:
1070
+ return Page(items=[await _download_profile(ctx, req, started)], has_more=False)
1071
+ if req.story:
1072
+ return Page(items=[await _download_story(ctx, req, started)], has_more=False)
1073
+ if req.file_id:
1074
+ return Page(items=[await _download_file_id(ctx, req, started)], has_more=False)
1075
+ if req.web or req.map:
1076
+ return Page(items=[await _download_web(ctx, req, started)], has_more=False)
1077
+
1078
+ if req.chat is None:
1079
+ raise UsageError(
1080
+ "a chat is required unless --profile/--story/--file-id/--web/--map is given",
1081
+ field="chat",
1082
+ )
1083
+ peer = await _send.resolve(ctx, req.chat)
1084
+ chat_id = _send.peer_id_of(peer)
1085
+ messages = await _download_targets(ctx, req, peer)
1086
+ if not messages:
1087
+ raise NotFoundError("nothing here matches what you asked for")
1088
+
1089
+ items: list[Downloaded] = []
1090
+ for message in messages:
1091
+ items.append(await _download_one(ctx, req, message, chat_id=chat_id, started=started))
1092
+ if req.read:
1093
+ await _mark_read(ctx, peer, [int(m.id) for m in messages])
1094
+ return Page(items=items, has_more=False, total=len(items))
1095
+
1096
+
1097
+ async def _download_targets(ctx: OpContext, req: DownloadReq, peer: Any) -> list[Any]:
1098
+ """Which messages this invocation is about, always freshly fetched."""
1099
+ if req.every:
1100
+ kwargs: dict[str, Any] = {
1101
+ "filter": _media.media_filter(req.type),
1102
+ "limit": req.max_items,
1103
+ }
1104
+ if req.from_user is not None:
1105
+ kwargs["from_user"] = await _send.resolve(ctx, req.from_user)
1106
+ if req.until:
1107
+ kwargs["offset_date"] = parse_dt(req.until)
1108
+ floor = to_unix(parse_dt(req.since)) if req.since else None
1109
+ found = [
1110
+ message
1111
+ async for message in _client(ctx).iter_messages(peer, **kwargs)
1112
+ if message is not None and getattr(message, "media", None) is not None
1113
+ ]
1114
+ if floor is not None:
1115
+ found = [m for m in found if (to_unix(getattr(m, "date", None)) or 0) >= floor]
1116
+ return found
1117
+
1118
+ ids = _ids_from(req.msg_id)
1119
+ if not ids:
1120
+ raise UsageError("give at least one message id, or --every", field="msg_id")
1121
+ found = [m for m in (await _client(ctx).get_messages(peer, ids=ids) or []) if m is not None]
1122
+ if req.album:
1123
+ found = await _with_album(ctx, peer, found)
1124
+ return found
1125
+
1126
+
1127
+ async def _with_album(ctx: OpContext, peer: Any, messages: list[Any]) -> list[Any]:
1128
+ seen = {int(m.id) for m in messages}
1129
+ out = list(messages)
1130
+ for message in messages:
1131
+ grouped = getattr(message, "grouped_id", None)
1132
+ if grouped is None:
1133
+ continue
1134
+ message_id = int(message.id)
1135
+ window = [i for i in range(max(1, message_id - 10), message_id + 11) if i not in seen]
1136
+ for sibling in await _client(ctx).get_messages(peer, ids=window) or []:
1137
+ if sibling is None or getattr(sibling, "grouped_id", None) != grouped:
1138
+ continue
1139
+ seen.add(int(sibling.id))
1140
+ out.append(sibling)
1141
+ return sorted(out, key=lambda m: int(getattr(m, "id", 0)))
1142
+
1143
+
1144
+ def _target_for(ctx: OpContext, req: DownloadReq, message: Any, chat_id: int) -> Path:
1145
+ """Where this item lands, with a server-supplied name that cannot escape."""
1146
+ media = getattr(message, "media", None)
1147
+ document = _media.document_of(media)
1148
+ facts = _media.attributes_of(document) if document is not None else {}
1149
+ date, _ = _media.message_dates(message)
1150
+ name = _safe_name(facts.get("file_name") or _default_name(media))
1151
+ if req.out and not req.stdout:
1152
+ return Path(os.path.expanduser(req.out))
1153
+ directory = (
1154
+ Path(os.path.expanduser(req.out_dir))
1155
+ if req.out_dir
1156
+ else _downloads_root(ctx) / str(chat_id)
1157
+ )
1158
+ return directory / _fill_template(
1159
+ req.name_template, date=date, message_id=int(getattr(message, "id", 0) or 0), name=name
1160
+ )
1161
+
1162
+
1163
+ def _default_name(media: Any) -> str:
1164
+ document = _media.document_of(media)
1165
+ if document is None:
1166
+ return "photo.jpg"
1167
+ mime = getattr(document, "mime_type", "") or ""
1168
+ extension = {
1169
+ "image/webp": ".webp",
1170
+ "video/mp4": ".mp4",
1171
+ "video/webm": ".webm",
1172
+ "audio/ogg": ".ogg",
1173
+ "audio/mpeg": ".mp3",
1174
+ "application/x-tgsticker": ".tgs",
1175
+ }.get(mime, ".bin")
1176
+ return f"{getattr(document, 'id', 'file')}{extension}"
1177
+
1178
+
1179
+ async def _download_one(
1180
+ ctx: OpContext, req: DownloadReq, message: Any, *, chat_id: int, started: float
1181
+ ) -> Downloaded:
1182
+ media = getattr(message, "media", None)
1183
+ if media is None:
1184
+ raise NotFoundError(f"message {getattr(message, 'id', '?')} carries no media")
1185
+ if getattr(message, "noforwards", False) and not req.allow_protected:
1186
+ raise PermissionError_(
1187
+ f"message {message.id} is in a chat that forbids saving content; "
1188
+ "pass --allow-protected to download it anyway"
1189
+ )
1190
+
1191
+ target = _target_for(ctx, req, message, chat_id)
1192
+ document = _media.document_of(media)
1193
+ photo = _media.photo_of(media) if document is None else None
1194
+ from tlgr.ops._serialize import media_summary
1195
+
1196
+ summary = media_summary(media)
1197
+ kind = summary.kind if summary is not None else "file"
1198
+
1199
+ if req.thumb:
1200
+ path = await _download_thumb(ctx, req, message, target)
1201
+ return Downloaded(
1202
+ msg_id=int(message.id),
1203
+ chat_id=chat_id,
1204
+ path=str(path),
1205
+ bytes=path.stat().st_size if path.exists() else 0,
1206
+ kind="thumb",
1207
+ mime=getattr(document, "mime_type", None),
1208
+ elapsed_s=_elapsed(started),
1209
+ )
1210
+
1211
+ location = _quality_pick(media, req.quality) if req.quality else (document or photo)
1212
+ size = int(getattr(location, "size", 0) or 0)
1213
+ if req.skip_existing and target.exists() and (not size or target.stat().st_size == size):
1214
+ return Downloaded(
1215
+ msg_id=int(message.id),
1216
+ chat_id=chat_id,
1217
+ path=str(target),
1218
+ bytes=target.stat().st_size,
1219
+ kind=kind,
1220
+ skipped=True,
1221
+ )
1222
+ if not req.overwrite:
1223
+ target = _unique(target)
1224
+
1225
+ offset, limit = _byte_range(req.range)
1226
+ peer_ref = getattr(message, "peer_id", None)
1227
+
1228
+ async def refresh() -> Any:
1229
+ fresh = await _media.fetch_message(ctx, peer_ref, int(message.id))
1230
+ return _media.document_of(fresh.media) or _media.photo_of(fresh.media)
1231
+
1232
+ path = await _download_bytes(
1233
+ ctx,
1234
+ location,
1235
+ target,
1236
+ size=size,
1237
+ dc_id=int(getattr(location, "dc_id", 0) or 0),
1238
+ offset=offset,
1239
+ limit=limit,
1240
+ resume=req.resume,
1241
+ part_size=req.part_size * 1024,
1242
+ connections=req.connections,
1243
+ refresh=refresh,
1244
+ )
1245
+ digest = None
1246
+ if req.verify:
1247
+ digest = await _verify(ctx, location, path)
1248
+ if req.stdout:
1249
+ ctx.warn(
1250
+ "the daemon cannot write bytes to your terminal through the IPC socket; "
1251
+ "the file was spooled and its path is in `path`"
1252
+ )
1253
+ return Downloaded(
1254
+ msg_id=int(message.id),
1255
+ chat_id=chat_id,
1256
+ path=str(path),
1257
+ bytes=path.stat().st_size if path.exists() else 0,
1258
+ kind=kind,
1259
+ mime=getattr(document, "mime_type", None),
1260
+ sha256=digest,
1261
+ file_id=_media.file_id_of(media),
1262
+ elapsed_s=_elapsed(started),
1263
+ )
1264
+
1265
+
1266
+ def _quality_pick(media: Any, wanted: str) -> Any:
1267
+ """`--quality 720` → the matching `alt_documents` transcode."""
1268
+ alternatives = list(getattr(media, "alt_documents", None) or [])
1269
+ if not alternatives:
1270
+ return _media.document_of(media)
1271
+ if wanted in ("original", "best"):
1272
+ return _media.document_of(media)
1273
+ if wanted == "smallest":
1274
+ return min(alternatives, key=lambda d: int(getattr(d, "size", 0) or 0))
1275
+ for document in alternatives:
1276
+ facts = _media.attributes_of(document)
1277
+ if str(facts.get("height") or "") == wanted or str(facts.get("width") or "") == wanted:
1278
+ return document
1279
+ raise UsageError(f"--quality {wanted!r}: this video has no such transcode", field="quality")
1280
+
1281
+
1282
+ async def _download_thumb(ctx: OpContext, req: DownloadReq, message: Any, target: Path) -> Path:
1283
+ """A thumbnail, including the two that need no request at all.
1284
+
1285
+ `photoStrippedSize` and `photoPathSize` are already in the message: the
1286
+ stripped one inflates to a JPEG locally and the vector one is an SVG
1287
+ outline. Fetching them over the network would be a round trip for bytes
1288
+ already in hand.
1289
+ """
1290
+ from telethon import utils
1291
+
1292
+ media = getattr(message, "media", None)
1293
+ document = _media.document_of(media)
1294
+ photo = _media.photo_of(media) if document is None else None
1295
+ sizes = list(getattr(photo or document, "sizes", None) or []) or list(
1296
+ getattr(document, "thumbs", None) or []
1297
+ )
1298
+ target.parent.mkdir(parents=True, exist_ok=True)
1299
+
1300
+ if req.thumb in ("stripped", "vector"):
1301
+ wanted = "PhotoStrippedSize" if req.thumb == "stripped" else "PhotoPathSize"
1302
+ for size in sizes:
1303
+ if type(size).__name__ != wanted:
1304
+ continue
1305
+ raw = getattr(size, "bytes", b"") or b""
1306
+ data = utils.stripped_photo_to_jpg(raw) if req.thumb == "stripped" else raw
1307
+ target.write_bytes(data)
1308
+ return target
1309
+ raise NotFoundError(f"this media carries no {req.thumb} thumbnail")
1310
+
1311
+ selector: Any = req.thumb
1312
+ if str(req.thumb).lstrip("-").isdigit():
1313
+ selector = int(str(req.thumb))
1314
+ elif req.thumb == "smallest":
1315
+ selector = 0
1316
+ elif req.thumb == "largest":
1317
+ selector = -1
1318
+ written = await _client(ctx).download_media(message, file=str(target), thumb=selector)
1319
+ return Path(written or target)
1320
+
1321
+
1322
+ async def _verify(ctx: OpContext, location: Any, path: Path) -> str:
1323
+ """Check the file against `upload.getFileHashes`, block by block."""
1324
+ from telethon.tl.functions import upload as fn
1325
+
1326
+ hashes = await _client(ctx)(fn.GetFileHashesRequest(location=location, offset=0))
1327
+ with open(path, "rb") as handle:
1328
+ for entry in hashes or []:
1329
+ offset = int(getattr(entry, "offset", 0) or 0)
1330
+ limit = int(getattr(entry, "limit", 0) or 0)
1331
+ handle.seek(offset)
1332
+ block = handle.read(limit)
1333
+ if hashlib.sha256(block).digest() != getattr(entry, "hash", b""):
1334
+ raise NotSupportedError(
1335
+ f"the bytes at offset {offset} do not match the server's hash; "
1336
+ "the download is corrupt"
1337
+ )
1338
+ return _sha256(path)
1339
+
1340
+
1341
+ async def _download_profile(ctx: OpContext, req: DownloadReq, started: float) -> Downloaded:
1342
+ peer = await _send.resolve(ctx, req.profile)
1343
+ directory = (
1344
+ Path(os.path.expanduser(req.out_dir)) if req.out_dir else _downloads_root(ctx) / "profile"
1345
+ )
1346
+ target = Path(os.path.expanduser(req.out)) if req.out else directory / "avatar.jpg"
1347
+ target.parent.mkdir(parents=True, exist_ok=True)
1348
+ written = await _client(ctx).download_profile_photo(
1349
+ peer, file=str(target), download_big=not req.small
1350
+ )
1351
+ if written is None:
1352
+ raise NotFoundError("that peer has no profile photo")
1353
+ path = Path(written)
1354
+ return Downloaded(
1355
+ chat_id=_send.peer_id_of(peer),
1356
+ path=str(path),
1357
+ bytes=path.stat().st_size if path.exists() else 0,
1358
+ kind="profile_photo",
1359
+ elapsed_s=_elapsed(started),
1360
+ )
1361
+
1362
+
1363
+ async def _download_story(ctx: OpContext, req: DownloadReq, started: float) -> Downloaded:
1364
+ """`--story peer:id` — a story's media is an ordinary photo or document.
1365
+
1366
+ The reference inside a `StoryItem` expires like any other, which is why
1367
+ the story is fetched here rather than taken from a cached listing.
1368
+ """
1369
+ from telethon.tl.functions import stories as fn
1370
+
1371
+ reference, _, story_id = str(req.story).rpartition(":")
1372
+ if not reference or not story_id.lstrip("-").isdigit():
1373
+ raise UsageError("--story takes PEER:ID, e.g. --story @alice:42", field="story")
1374
+ from tlgr.models.peer import parse_peer_ref
1375
+
1376
+ peer = await _send.resolve(ctx, parse_peer_ref(reference))
1377
+ result = await _client(ctx)(fn.GetStoriesByIDRequest(peer=peer, id=[int(story_id)]))
1378
+ stories = list(getattr(result, "stories", None) or [])
1379
+ if not stories:
1380
+ raise NotFoundError(f"story {story_id} is not available")
1381
+ media = getattr(stories[0], "media", None)
1382
+ document = _media.document_of(media) or _media.photo_of(media)
1383
+ directory = (
1384
+ Path(os.path.expanduser(req.out_dir)) if req.out_dir else _downloads_root(ctx) / "stories"
1385
+ )
1386
+ target = Path(os.path.expanduser(req.out)) if req.out else directory / f"story_{story_id}.bin"
1387
+ path = await _download_bytes(
1388
+ ctx,
1389
+ document,
1390
+ target,
1391
+ size=int(getattr(document, "size", 0) or 0),
1392
+ dc_id=int(getattr(document, "dc_id", 0) or 0),
1393
+ resume=req.resume,
1394
+ part_size=req.part_size * 1024,
1395
+ connections=req.connections,
1396
+ )
1397
+ return Downloaded(
1398
+ path=str(path),
1399
+ bytes=path.stat().st_size if path.exists() else 0,
1400
+ kind="story",
1401
+ msg_id=int(story_id),
1402
+ chat_id=_send.peer_id_of(peer),
1403
+ elapsed_s=_elapsed(started),
1404
+ )
1405
+
1406
+
1407
+ async def _download_file_id(ctx: OpContext, req: DownloadReq, started: float) -> Downloaded:
1408
+ from telethon import utils
1409
+
1410
+ try:
1411
+ location = utils.resolve_bot_file_id(req.file_id)
1412
+ except Exception as exc:
1413
+ raise UsageError(f"--file-id: {exc}", field="file_id") from exc
1414
+ if location is None:
1415
+ raise UsageError("--file-id: that is not a Telegram file id", field="file_id")
1416
+ directory = (
1417
+ Path(os.path.expanduser(req.out_dir)) if req.out_dir else _downloads_root(ctx) / "file-id"
1418
+ )
1419
+ target = (
1420
+ Path(os.path.expanduser(req.out))
1421
+ if req.out
1422
+ else directory / f"{getattr(location, 'id', 'file')}.bin"
1423
+ )
1424
+ path = await _download_bytes(
1425
+ ctx,
1426
+ location,
1427
+ target,
1428
+ size=int(getattr(location, "size", 0) or 0),
1429
+ dc_id=int(getattr(location, "dc_id", 0) or 0),
1430
+ resume=req.resume,
1431
+ part_size=req.part_size * 1024,
1432
+ connections=req.connections,
1433
+ )
1434
+ return Downloaded(
1435
+ path=str(path),
1436
+ bytes=path.stat().st_size if path.exists() else 0,
1437
+ kind="file_id",
1438
+ file_id=req.file_id,
1439
+ elapsed_s=_elapsed(started),
1440
+ )
1441
+
1442
+
1443
+ async def _download_web(ctx: OpContext, req: DownloadReq, started: float) -> Downloaded:
1444
+ """`upload.getWebFile`: an inline-bot document, or a static map preview."""
1445
+ from telethon.tl import types
1446
+ from telethon.tl.functions import upload as fn
1447
+
1448
+ width, _, height = req.size.lower().partition("x")
1449
+ if req.map:
1450
+ latitude, _, longitude = str(req.map).partition(",")
1451
+ try:
1452
+ point = types.InputGeoPoint(lat=float(latitude), long=float(longitude))
1453
+ except ValueError as exc:
1454
+ raise UsageError("--map takes LAT,LON", field="map") from exc
1455
+ location: Any = types.InputWebFileGeoPointLocation(
1456
+ geo_point=point,
1457
+ access_hash=0,
1458
+ w=int(width or 600),
1459
+ h=int(height or 400),
1460
+ zoom=req.zoom,
1461
+ scale=1,
1462
+ )
1463
+ name = f"map_{latitude}_{longitude}.png"
1464
+ else:
1465
+ location = types.InputWebFileLocation(url=str(req.web), access_hash=0)
1466
+ name = _safe_name(str(req.web).rsplit("/", 1)[-1] or "webfile.bin")
1467
+
1468
+ directory = (
1469
+ Path(os.path.expanduser(req.out_dir)) if req.out_dir else _downloads_root(ctx) / "web"
1470
+ )
1471
+ target = Path(os.path.expanduser(req.out)) if req.out else directory / name
1472
+ target.parent.mkdir(parents=True, exist_ok=True)
1473
+ chunk_size = 512 * 1024
1474
+ written = 0
1475
+ with open(target, "wb") as handle:
1476
+ offset = 0
1477
+ while True:
1478
+ part = await _client(ctx)(
1479
+ fn.GetWebFileRequest(location=location, offset=offset, limit=chunk_size)
1480
+ )
1481
+ data = getattr(part, "bytes", b"") or b""
1482
+ handle.write(data)
1483
+ written += len(data)
1484
+ if len(data) < chunk_size:
1485
+ break
1486
+ offset += len(data)
1487
+ return Downloaded(
1488
+ path=str(target),
1489
+ bytes=written,
1490
+ kind="web",
1491
+ mime=getattr(part, "mime_type", None),
1492
+ elapsed_s=_elapsed(started),
1493
+ )
1494
+
1495
+
1496
+ async def _mark_read(ctx: OpContext, peer: Any, ids: list[int]) -> None:
1497
+ from telethon import utils
1498
+ from telethon.tl.functions import channels as ch
1499
+ from telethon.tl.functions import messages as fn
1500
+
1501
+ try:
1502
+ channel = utils.get_input_channel(peer)
1503
+ except (TypeError, ValueError):
1504
+ channel = None
1505
+ if channel is not None:
1506
+ await _client(ctx)(ch.ReadMessageContentsRequest(channel=channel, id=ids))
1507
+ else:
1508
+ await _client(ctx)(fn.ReadMessageContentsRequest(id=ids))
1509
+
1510
+
1511
+ async def _background_download(ctx: OpContext, req: DownloadReq) -> Page[Downloaded]:
1512
+ """Hand the transfer to the daemon and answer with a job id."""
1513
+ store = _transfers(ctx)
1514
+ background = req.__class__(**{**_as_dict(req), "background": False})
1515
+
1516
+ async def run() -> Any:
1517
+ return await download(ctx, background)
1518
+
1519
+ record = store.submit(
1520
+ direction="download",
1521
+ name=str(req.out or req.out_dir or "download"),
1522
+ chat_id=_send.peer_id_of(await _send.resolve(ctx, req.chat)) if req.chat else None,
1523
+ factory=run,
1524
+ )
1525
+ return Page(
1526
+ items=[Downloaded(job_id=record.job_id, kind="queued", path="")],
1527
+ has_more=False,
1528
+ total=1,
1529
+ )
1530
+
1531
+
1532
+ def _as_dict(request: Any) -> dict[str, Any]:
1533
+ """A request's own fields, so a copy can flip one of them."""
1534
+ import msgspec
1535
+
1536
+ return {field.name: getattr(request, field.name) for field in msgspec.structs.fields(request)}
1537
+
1538
+
1539
+ SPEC_DOWNLOAD = OperationSpec(
1540
+ id="media.download",
1541
+ request=DownloadReq,
1542
+ response=Page[Downloaded],
1543
+ impl=download,
1544
+ summary="Download media from messages, profile photos, stories or a file id",
1545
+ description=(
1546
+ "The message is re-fetched before a byte is read, because a "
1547
+ "file_reference expires and the fix is a re-fetch rather than a "
1548
+ "retry. --resume continues from the .part sidecar, --connections runs "
1549
+ "parallel ranged readers, --verify checks every block against "
1550
+ "upload.getFileHashes, and --thumb stripped/vector costs no request at "
1551
+ "all. --read is opt-in: without it a view-once photo stays unviewed "
1552
+ "for the sender, and with it the consumption is irreversible."
1553
+ ),
1554
+ aliases=("dl",),
1555
+ legacy_paths=("media download", "dl"),
1556
+ rate_class="file",
1557
+ timeout_s=900,
1558
+ columns=("msg_id", "path", "bytes", "kind"),
1559
+ headers=("ID", "Path", "Bytes", "Kind"),
1560
+ example={"items": [_EXAMPLE_DOWNLOAD], "has_more": False},
1561
+ example_args="media download @alice 12345",
1562
+ covers=(
1563
+ "media.download-album",
1564
+ "media.download-cdn-redirect",
1565
+ "media.download-message-media",
1566
+ "media.download-profile-photo",
1567
+ "media.download-range-resume",
1568
+ "media.download-thumbnail",
1569
+ "media.download-verify-hashes",
1570
+ "media.download-web-file",
1571
+ "media.parallel-transfer",
1572
+ "stories.download-media",
1573
+ "stories.story-video-alt-quality",
1574
+ ),
1575
+ covers_partial=(
1576
+ "media.background-transfer-jobs",
1577
+ "media.dc-routing",
1578
+ "media.download-batch",
1579
+ "media.download-stream-stdout",
1580
+ "media.file-id-export-import",
1581
+ "media.file-reference-refresh",
1582
+ "media.music-player-queue",
1583
+ "media.shared-media-bulk-actions",
1584
+ "media.stripped-vector-thumbnails",
1585
+ "media.transfer-progress",
1586
+ "media.video-quality-select",
1587
+ "media.view-self-destructing",
1588
+ ),
1589
+ coverage_note=(
1590
+ "The daemon owns the connection, so it cannot write bytes to the caller's "
1591
+ "terminal: --stdout spools the file and reports its path, and --play is "
1592
+ "refused rather than having the daemon spawn a player."
1593
+ ),
1594
+ )
1595
+
1596
+
1597
+ # ---------------------------------------------------------------------------
1598
+ # media upload
1599
+ # ---------------------------------------------------------------------------
1600
+
1601
+
1602
+ class UploadReq(_send.SendOptions, kw_only=True):
1603
+ chat: Annotated[PeerRef, arg(0, metavar="CHAT", kind="peer", help="Where to send it.")]
1604
+ path: Annotated[
1605
+ list[str],
1606
+ arg(
1607
+ 1,
1608
+ metavar="PATH",
1609
+ required=False,
1610
+ variadic=True,
1611
+ kind="path",
1612
+ help="Files; `-` is stdin.",
1613
+ ),
1614
+ ] = []
1615
+ send_as_kind: Annotated[
1616
+ str,
1617
+ choice(
1618
+ "auto",
1619
+ "photo",
1620
+ "file",
1621
+ "video",
1622
+ "gif",
1623
+ "audio",
1624
+ "voice",
1625
+ "round",
1626
+ "sticker",
1627
+ help="Force the media kind instead of sniffing it.",
1628
+ ),
1629
+ ] = "auto"
1630
+ album: Annotated[bool, opt("--album", help="Group 2..10 items into one message group.")] = True
1631
+ name: Annotated[
1632
+ str | None, opt("--name", metavar="NAME", help="Override the remote file name.")
1633
+ ] = None
1634
+ mime: Annotated[str | None, opt("--mime", metavar="TYPE", help="Override the MIME type.")] = (
1635
+ None
1636
+ )
1637
+ url: Annotated[
1638
+ str | None, opt("--url", metavar="URL", help="Let Telegram fetch the media itself.")
1639
+ ] = None
1640
+ fetch_local: Annotated[
1641
+ bool, opt("--fetch-local", help="With --url: download locally first, then upload.")
1642
+ ] = False
1643
+ file_id: Annotated[
1644
+ str | None, opt("--file-id", metavar="ID", help="Re-send media already on Telegram.")
1645
+ ] = None
1646
+ from_message: Annotated[
1647
+ str | None,
1648
+ opt("--from-message", metavar="CHAT:ID", help="Re-send an existing message's media."),
1649
+ ] = None
1650
+ dice: Annotated[
1651
+ str | None, opt("--dice", metavar="EMOJI", help="Send an animated dice instead of a file.")
1652
+ ] = None
1653
+ thumb: Annotated[
1654
+ str | None, opt("--thumb", metavar="PATH", help="Cover thumbnail; `auto` extracts a frame.")
1655
+ ] = None
1656
+ cover: Annotated[str | None, opt("--cover", metavar="PATH", help="Video cover photo.")] = None
1657
+ start_at: Annotated[
1658
+ int | None,
1659
+ opt("--start-at", metavar="SECONDS", kind="duration", help="Video start offset."),
1660
+ ] = None
1661
+ duration: Annotated[
1662
+ int | None, opt("--duration", metavar="SECONDS", kind="duration", help="Explicit duration.")
1663
+ ] = None
1664
+ width: Annotated[int | None, opt("--width", metavar="PX", help="Explicit width.")] = None
1665
+ height: Annotated[int | None, opt("--height", metavar="PX", help="Explicit height.")] = None
1666
+ streaming: Annotated[bool, opt("--streaming", help="Mark the video streamable.")] = True
1667
+ no_sound: Annotated[
1668
+ bool, opt("--no-sound", help="Silent MP4 that must stay a video, not become a GIF.")
1669
+ ] = False
1670
+ title: Annotated[str | None, opt("--title", metavar="TEXT", help="Audio title.")] = None
1671
+ performer: Annotated[
1672
+ str | None, opt("--performer", metavar="TEXT", help="Audio performer.")
1673
+ ] = None
1674
+ waveform: Annotated[str, choice("auto", "none", help="Voice-note waveform.")] = "auto"
1675
+ spoiler: Annotated[bool, opt("--spoiler", help="Blur until tapped.")] = False
1676
+ ttl: Annotated[
1677
+ int | None, opt("--ttl", metavar="SECONDS", kind="duration", help="Self-destruct timer.")
1678
+ ] = None
1679
+ once: Annotated[bool, opt("--once", help="View-once / play-once.")] = False
1680
+ quality: Annotated[
1681
+ str, choice("high", "standard", help="Original bytes, or downscale first.")
1682
+ ] = "high"
1683
+ live_video: Annotated[
1684
+ str | None,
1685
+ opt("--live-video", metavar="PATH", kind="path", help="Companion Live Photo video."),
1686
+ ] = None
1687
+ attached_sticker: Annotated[
1688
+ list[str],
1689
+ opt(
1690
+ "--attached-sticker",
1691
+ metavar="STICKER",
1692
+ help="Sticker already composited in (<set>/<n>).",
1693
+ ),
1694
+ ] = []
1695
+ dedupe: Annotated[
1696
+ bool, opt("--dedupe", help="Ask the server for the file by hash and skip the upload.")
1697
+ ] = False
1698
+ no_send: Annotated[
1699
+ bool, opt("--no-send", help="Upload only, and print the reusable file id.")
1700
+ ] = False
1701
+ part_size: Annotated[
1702
+ int, opt("--part-size", metavar="KB", help="Upload part size in KB.", ge=4, le=512)
1703
+ ] = 512
1704
+ connections: Annotated[
1705
+ int, opt("--connections", metavar="N", help="Parallel part uploaders.", ge=1, le=8)
1706
+ ] = 4
1707
+ progress: Annotated[
1708
+ bool, opt("--progress", help="Emit progress onto the event bus while uploading.")
1709
+ ] = False
1710
+ background: Annotated[
1711
+ bool, opt("--background", help="Upload inside the daemon and print a job id.")
1712
+ ] = False
1713
+ no_action: Annotated[
1714
+ bool, opt("--no-action", help="Do not broadcast the 'sending a photo…' chat action.")
1715
+ ] = False
1716
+ wait: Annotated[
1717
+ bool, opt("--wait", help="Wait for server-side video conversion to finish.")
1718
+ ] = False
1719
+ caption: Annotated[
1720
+ list[str],
1721
+ opt("--caption", metavar="TEXT", help="Caption; repeat once per album item."),
1722
+ ] = []
1723
+ caption_file: Annotated[
1724
+ str | None, opt("--caption-file", metavar="PATH", kind="path", help="Caption from a file.")
1725
+ ] = None
1726
+ parse: Annotated[str | None, choice("md", "html", "none", help="Caption parse mode.")] = None
1727
+ entities: Annotated[
1728
+ str | None, opt("--entities", metavar="JSON", kind="json", help="Explicit entities.")
1729
+ ] = None
1730
+ caption_above: Annotated[
1731
+ bool, opt("--caption-above", help="Render the caption above the media.")
1732
+ ] = False
1733
+ reply_to: Annotated[
1734
+ int | None, opt("--reply-to", metavar="ID", kind="msg_id", help="Reply to this message.")
1735
+ ] = None
1736
+ quote: Annotated[
1737
+ str | None, opt("--quote", metavar="TEXT", help="Quoted fragment of the reply target.")
1738
+ ] = None
1739
+ clear_draft: Annotated[bool, opt("--clear-draft", help="Clear the chat draft on success.")] = (
1740
+ True
1741
+ )
1742
+
1743
+
1744
+ def _upload_sources(req: UploadReq) -> list[Path]:
1745
+ """The local files, with `-` spooled off stdin.
1746
+
1747
+ Spooling rather than streaming: a photo cannot be uploaded with an unknown
1748
+ part count, and the temp file is also what makes `--dedupe` and the
1749
+ pre-flight size check possible at all.
1750
+ """
1751
+ import sys
1752
+ import tempfile
1753
+
1754
+ out: list[Path] = []
1755
+ for entry in req.path:
1756
+ if entry != "-":
1757
+ path = Path(os.path.expanduser(entry))
1758
+ if not path.exists():
1759
+ raise UsageError(f"{entry} does not exist", field="path")
1760
+ out.append(path)
1761
+ continue
1762
+ if sys.stdin is None or sys.stdin.isatty():
1763
+ raise UsageError("'-' was given for the file but stdin is a terminal", field="path")
1764
+ handle = tempfile.NamedTemporaryFile( # noqa: SIM115 - closed below, kept on disk
1765
+ prefix="tlgr-", suffix=Path(req.name or "stdin.txt").suffix or ".bin", delete=False
1766
+ )
1767
+ handle.write(sys.stdin.buffer.read())
1768
+ handle.close()
1769
+ out.append(Path(handle.name))
1770
+ return out
1771
+
1772
+
1773
+ _PHOTO_SUFFIXES = (".jpg", ".jpeg", ".png", ".webp", ".bmp", ".heic")
1774
+
1775
+
1776
+ def _kind_for(path: Path, wanted: str) -> str:
1777
+ """`auto` → the kind the bytes are, and every explicit choice untouched."""
1778
+ if wanted != "auto":
1779
+ return wanted
1780
+ import mimetypes
1781
+
1782
+ suffix = path.suffix.lower()
1783
+ if suffix in _PHOTO_SUFFIXES:
1784
+ return "photo"
1785
+ mime = mimetypes.guess_type(path.name)[0] or ""
1786
+ if suffix == ".gif" or mime == "image/gif":
1787
+ return "gif"
1788
+ if mime.startswith("video/"):
1789
+ return "video"
1790
+ if mime.startswith("audio/"):
1791
+ return "audio"
1792
+ return "file"
1793
+
1794
+
1795
+ def _attributes_for(ctx: OpContext, req: UploadReq, path: Path, kind: str) -> tuple[list[Any], str]:
1796
+ """Document attributes plus a mime type, probed rather than guessed.
1797
+
1798
+ A video sent with `duration=0, w=1, h=1` renders as a 1×1 black rectangle
1799
+ in every client, so when nothing on the machine can read the file the
1800
+ caller is *warned* rather than silently sent a broken message.
1801
+ """
1802
+ import mimetypes
1803
+
1804
+ from telethon.tl import types
1805
+
1806
+ from tlgr.core.media import infer_attributes
1807
+
1808
+ facts, warnings = infer_attributes(path)
1809
+ for warning in warnings:
1810
+ ctx.warn(warning)
1811
+ duration = int(req.duration if req.duration is not None else facts.get("duration") or 0)
1812
+ width = int(req.width if req.width is not None else facts.get("width") or 0)
1813
+ height = int(req.height if req.height is not None else facts.get("height") or 0)
1814
+ mime = req.mime or mimetypes.guess_type(path.name)[0] or "application/octet-stream"
1815
+ attributes: list[Any] = [
1816
+ types.DocumentAttributeFilename(file_name=_safe_name(req.name or path.name))
1817
+ ]
1818
+
1819
+ if kind == "voice":
1820
+ attributes.append(types.DocumentAttributeAudio(duration=duration, voice=True))
1821
+ mime = req.mime or "audio/ogg"
1822
+ if req.waveform == "auto":
1823
+ ctx.warn("a waveform needs ffmpeg; the voice note is sent without one")
1824
+ elif kind == "round":
1825
+ attributes.append(
1826
+ types.DocumentAttributeVideo(
1827
+ duration=duration, w=width or 384, h=height or 384, round_message=True
1828
+ )
1829
+ )
1830
+ mime = req.mime or "video/mp4"
1831
+ elif kind == "video":
1832
+ attributes.append(
1833
+ types.DocumentAttributeVideo(
1834
+ duration=duration,
1835
+ w=width,
1836
+ h=height,
1837
+ supports_streaming=req.streaming,
1838
+ nosound=req.no_sound or None,
1839
+ )
1840
+ )
1841
+ elif kind == "gif":
1842
+ attributes.append(types.DocumentAttributeAnimated())
1843
+ attributes.append(
1844
+ types.DocumentAttributeVideo(
1845
+ duration=duration, w=width, h=height, nosound=req.no_sound or None
1846
+ )
1847
+ )
1848
+ elif kind == "audio":
1849
+ attributes.append(
1850
+ types.DocumentAttributeAudio(
1851
+ duration=duration, title=req.title, performer=req.performer
1852
+ )
1853
+ )
1854
+ elif kind == "sticker":
1855
+ from telethon.tl.types import InputStickerSetEmpty
1856
+
1857
+ attributes.append(types.DocumentAttributeSticker(alt="", stickerset=InputStickerSetEmpty()))
1858
+ mime = req.mime or "image/webp"
1859
+ return attributes, mime
1860
+
1861
+
1862
+ def _ttl_seconds(req: UploadReq) -> int | None:
1863
+ """`--once` is the sentinel 0x7FFFFFFF, not a very long timer."""
1864
+ if req.once:
1865
+ return 0x7FFFFFFF
1866
+ return req.ttl
1867
+
1868
+
1869
+ async def _uploaded_handle(ctx: OpContext, req: UploadReq, path: Path) -> Any:
1870
+ upload = getattr(ctx, "upload_file", None)
1871
+ if upload is None: # pragma: no cover - the daemon always supplies one
1872
+ raise UsageError("this context cannot upload files")
1873
+ return await upload(
1874
+ path,
1875
+ part_size=req.part_size * 1024,
1876
+ parts_in_flight=req.connections,
1877
+ file_name=_safe_name(req.name or path.name),
1878
+ )
1879
+
1880
+
1881
+ async def _thumb_handle(ctx: OpContext, req: UploadReq) -> Any:
1882
+ if not req.thumb:
1883
+ return None
1884
+ if req.thumb == "auto":
1885
+ ctx.warn("--thumb auto needs ffmpeg; the document is sent without a cover")
1886
+ return None
1887
+ path = Path(os.path.expanduser(req.thumb))
1888
+ if not path.exists():
1889
+ raise UsageError(f"--thumb: {req.thumb} does not exist", field="thumb")
1890
+ upload = getattr(ctx, "upload_file", None)
1891
+ return await upload(path) if upload is not None else None
1892
+
1893
+
1894
+ async def _input_media_for(ctx: OpContext, req: UploadReq, path: Path) -> tuple[Any, str]:
1895
+ """One local file as an `InputMedia`, with every flag Telethon lacks."""
1896
+ from telethon.tl import types
1897
+
1898
+ kind = _kind_for(path, req.send_as_kind)
1899
+ if req.quality == "standard":
1900
+ raise NotSupportedError(
1901
+ "--quality standard re-encodes with ffmpeg/Pillow, which this build does "
1902
+ "not do; send the original with --quality high"
1903
+ )
1904
+ stickers = await _attached_stickers(ctx, req)
1905
+ ttl = _ttl_seconds(req)
1906
+
1907
+ if kind == "photo":
1908
+ handle = await _uploaded_handle(ctx, req, path)
1909
+ video = None
1910
+ if req.live_video:
1911
+ video = await _live_photo_video(ctx, req)
1912
+ return (
1913
+ types.InputMediaUploadedPhoto(
1914
+ file=handle,
1915
+ spoiler=req.spoiler or None,
1916
+ ttl_seconds=ttl,
1917
+ stickers=stickers or None,
1918
+ live_photo=bool(req.live_video) or None,
1919
+ video=video,
1920
+ ),
1921
+ kind,
1922
+ )
1923
+
1924
+ attributes, mime = _attributes_for(ctx, req, path, kind)
1925
+ handle = await _uploaded_handle(ctx, req, path)
1926
+ return (
1927
+ types.InputMediaUploadedDocument(
1928
+ file=handle,
1929
+ mime_type=mime,
1930
+ attributes=attributes,
1931
+ thumb=await _thumb_handle(ctx, req),
1932
+ force_file=kind == "file" and req.send_as_kind == "file",
1933
+ nosound_video=req.no_sound or None,
1934
+ spoiler=req.spoiler or None,
1935
+ stickers=stickers or None,
1936
+ video_cover=await _cover_photo(ctx, req),
1937
+ video_timestamp=req.start_at,
1938
+ ttl_seconds=ttl,
1939
+ ),
1940
+ kind,
1941
+ )
1942
+
1943
+
1944
+ async def _attached_stickers(ctx: OpContext, req: UploadReq) -> list[Any]:
1945
+ """`--attached-sticker <set>/<n>` — declared, never composited.
1946
+
1947
+ tlgr cannot draw a sticker onto an image; it can only declare stickers the
1948
+ caller already burned in, which is what the flag means in every client.
1949
+ """
1950
+ if not req.attached_sticker:
1951
+ return []
1952
+ documents = await _media.resolve_stickers(ctx, list(req.attached_sticker))
1953
+ return [_media.input_document(document) for document in documents]
1954
+
1955
+
1956
+ async def _live_photo_video(ctx: OpContext, req: UploadReq) -> Any:
1957
+ from telethon.tl import types
1958
+ from telethon.tl.functions import messages as fn
1959
+
1960
+ path = Path(os.path.expanduser(str(req.live_video)))
1961
+ if not path.exists():
1962
+ raise UsageError(f"--live-video: {path} does not exist", field="live_video")
1963
+ upload = getattr(ctx, "upload_file", None)
1964
+ if upload is None: # pragma: no cover - the daemon always supplies one
1965
+ raise UsageError("this context cannot upload files")
1966
+ handle = await upload(path)
1967
+ result = await _client(ctx)(
1968
+ fn.UploadMediaRequest(
1969
+ peer=types.InputPeerSelf(),
1970
+ media=types.InputMediaUploadedDocument(
1971
+ file=handle, mime_type="video/mp4", attributes=[]
1972
+ ),
1973
+ )
1974
+ )
1975
+ document = _media.document_of(getattr(result, "document", result))
1976
+ return _media.input_document(document) if document is not None else None
1977
+
1978
+
1979
+ async def _cover_photo(ctx: OpContext, req: UploadReq) -> Any:
1980
+ """`--cover` must be an `InputPhoto`, so the file is uploaded first."""
1981
+ if not req.cover:
1982
+ return None
1983
+ from telethon.tl import types
1984
+ from telethon.tl.functions import messages as fn
1985
+
1986
+ path = Path(os.path.expanduser(req.cover))
1987
+ if not path.exists():
1988
+ raise UsageError(f"--cover: {req.cover} does not exist", field="cover")
1989
+ upload = getattr(ctx, "upload_file", None)
1990
+ if upload is None: # pragma: no cover - the daemon always supplies one
1991
+ raise UsageError("this context cannot upload files")
1992
+ handle = await upload(path)
1993
+ result = await _client(ctx)(
1994
+ fn.UploadMediaRequest(
1995
+ peer=types.InputPeerSelf(), media=types.InputMediaUploadedPhoto(file=handle)
1996
+ )
1997
+ )
1998
+ photo = _media.photo_of(getattr(result, "photo", result))
1999
+ return _media.input_photo(photo) if photo is not None else None
2000
+
2001
+
2002
+ async def _existing_media(ctx: OpContext, req: UploadReq) -> Any:
2003
+ """`--file-id` / `--from-message` / `--url` / `--dice`: no bytes move."""
2004
+ from telethon.tl import types
2005
+
2006
+ ttl = _ttl_seconds(req)
2007
+ if req.dice:
2008
+ return types.InputMediaDice(emoticon=req.dice)
2009
+ if req.url:
2010
+ if req.send_as_kind == "photo":
2011
+ return types.InputMediaPhotoExternal(
2012
+ url=req.url, spoiler=req.spoiler or None, ttl_seconds=ttl
2013
+ )
2014
+ return types.InputMediaDocumentExternal(
2015
+ url=req.url, spoiler=req.spoiler or None, ttl_seconds=ttl
2016
+ )
2017
+ if req.file_id:
2018
+ from telethon import utils
2019
+
2020
+ try:
2021
+ resolved = utils.resolve_bot_file_id(req.file_id)
2022
+ except Exception as exc:
2023
+ raise UsageError(f"--file-id: {exc}", field="file_id") from exc
2024
+ if resolved is None:
2025
+ raise UsageError("--file-id: that is not a Telegram file id", field="file_id")
2026
+ if type(resolved).__name__ == "Photo":
2027
+ return types.InputMediaPhoto(
2028
+ id=_media.input_photo(resolved), spoiler=req.spoiler or None
2029
+ )
2030
+ return types.InputMediaDocument(
2031
+ id=_media.input_document(resolved), spoiler=req.spoiler or None, ttl_seconds=ttl
2032
+ )
2033
+ if req.from_message:
2034
+ reference, _, message_id = str(req.from_message).rpartition(":")
2035
+ if not reference or not message_id.lstrip("-").isdigit():
2036
+ raise UsageError("--from-message takes CHAT:ID", field="from_message")
2037
+ from tlgr.models.peer import parse_peer_ref
2038
+
2039
+ source = await _send.resolve(ctx, parse_peer_ref(reference))
2040
+ message = await _media.fetch_message(ctx, source, int(message_id))
2041
+ media = getattr(message, "media", None)
2042
+ document = _media.document_of(media)
2043
+ if document is not None:
2044
+ return types.InputMediaDocument(
2045
+ id=_media.input_document(document), spoiler=req.spoiler or None, ttl_seconds=ttl
2046
+ )
2047
+ photo = _media.photo_of(media)
2048
+ if photo is None:
2049
+ raise NotFoundError(f"message {message_id} carries no re-sendable media")
2050
+ return types.InputMediaPhoto(id=_media.input_photo(photo), spoiler=req.spoiler or None)
2051
+ return None
2052
+
2053
+
2054
+ async def _dedupe_media(ctx: OpContext, path: Path) -> Any:
2055
+ """`messages.getDocumentByHash` — skip the upload when the server has it."""
2056
+ import mimetypes
2057
+
2058
+ from telethon.tl import types
2059
+ from telethon.tl.functions import messages as fn
2060
+
2061
+ digest = hashlib.sha256(path.read_bytes()).digest()
2062
+ mime = mimetypes.guess_type(path.name)[0] or "application/octet-stream"
2063
+ try:
2064
+ document = await _client(ctx)(
2065
+ fn.GetDocumentByHashRequest(sha256=digest, size=path.stat().st_size, mime_type=mime)
2066
+ )
2067
+ except Exception:
2068
+ return None
2069
+ if document is None or type(document).__name__ == "DocumentEmpty":
2070
+ return None
2071
+ return types.InputMediaDocument(id=_media.input_document(document))
2072
+
2073
+
2074
+ def _caption_for(req: UploadReq, index: int) -> tuple[str, list[Any]]:
2075
+ """The caption for item *index*, parsed once."""
2076
+ raw = ""
2077
+ if req.caption_file:
2078
+ raw = Path(os.path.expanduser(req.caption_file)).read_text(encoding="utf-8")
2079
+ elif req.caption:
2080
+ raw = (
2081
+ req.caption[index] if index < len(req.caption) else req.caption[0] if index == 0 else ""
2082
+ )
2083
+ text, entities = _send.body(raw, parse=req.parse, entities=req.entities)
2084
+ return text, _send.tl_entities(entities) or []
2085
+
2086
+
2087
+ async def _check_limits(ctx: OpContext, req: UploadReq, sources: list[Path]) -> None:
2088
+ """Refuse before the bandwidth is spent, not after."""
2089
+ values = await _media.app_config(ctx)
2090
+ premium = _is_premium(ctx)
2091
+ key = "upload_max_fileparts_premium" if premium else "upload_max_fileparts_default"
2092
+ max_parts = _media.config_int(values, key, 4000)
2093
+ part_size = req.part_size * 1024
2094
+ for path in sources:
2095
+ parts = max(1, -(-path.stat().st_size // part_size))
2096
+ if parts > max_parts:
2097
+ raise UsageError(
2098
+ f"{path.name} needs {parts} parts and this account may upload {max_parts}; "
2099
+ "the file is too large",
2100
+ field="path",
2101
+ )
2102
+ caption_limit = _media.config_int(
2103
+ values, "caption_length_limit_premium" if premium else "caption_length_limit_default", 1024
2104
+ )
2105
+ for index in range(max(1, len(sources))):
2106
+ text, _ = _caption_for(req, index)
2107
+ if caption_limit and len(text) > caption_limit:
2108
+ raise UsageError(
2109
+ f"the caption is {len(text)} characters and this account may send "
2110
+ f"{caption_limit}; send the text as a message or as a .txt file",
2111
+ field="caption",
2112
+ )
2113
+
2114
+
2115
+ async def upload(ctx: OpContext, req: UploadReq) -> Uploaded:
2116
+ """Send files as photo, video, document, audio, voice, round video or GIF."""
2117
+ started = time.monotonic()
2118
+ peer = await _send.resolve(ctx, req.chat)
2119
+ chat_id = _send.peer_id_of(peer)
2120
+ if req.background:
2121
+ return await _background_upload(ctx, req, chat_id)
2122
+
2123
+ existing = await _existing_media(ctx, req)
2124
+ sources: list[Path] = [] if existing is not None else _upload_sources(req)
2125
+ if existing is None and not sources:
2126
+ raise UsageError(
2127
+ "give at least one file, or --url/--file-id/--from-message/--dice", field="path"
2128
+ )
2129
+ if sources:
2130
+ await _check_limits(ctx, req, sources)
2131
+
2132
+ total_bytes = sum(path.stat().st_size for path in sources)
2133
+ action = None if req.no_action else _action_for(req, sources)
2134
+ if existing is not None:
2135
+ return await _send_one(ctx, req, peer, chat_id, existing, "existing", started, 0)
2136
+
2137
+ if len(sources) == 1:
2138
+ media = None
2139
+ if req.dedupe:
2140
+ media = await _dedupe_media(ctx, sources[0])
2141
+ if media is not None:
2142
+ ctx.warn("the server already had these bytes; nothing was uploaded")
2143
+ kind = _kind_for(sources[0], req.send_as_kind)
2144
+ if media is None:
2145
+ async with _typing(ctx, peer, action):
2146
+ media, kind = await _input_media_for(ctx, req, sources[0])
2147
+ if req.no_send:
2148
+ return await _upload_only(ctx, media, kind, total_bytes, started)
2149
+ return await _send_one(ctx, req, peer, chat_id, media, kind, started, total_bytes)
2150
+
2151
+ return await _send_album(ctx, req, peer, chat_id, sources, started, total_bytes)
2152
+
2153
+
2154
+ def _action_for(req: UploadReq, sources: list[Path]) -> str:
2155
+ kind = _kind_for(sources[0], req.send_as_kind) if sources else "file"
2156
+ return {
2157
+ "photo": "photo",
2158
+ "video": "video",
2159
+ "gif": "video",
2160
+ "audio": "audio",
2161
+ "voice": "audio",
2162
+ "round": "round",
2163
+ }.get(kind, "document")
2164
+
2165
+
2166
+ @contextlib.asynccontextmanager
2167
+ async def _typing(ctx: OpContext, peer: Any, action: str | None) -> Any:
2168
+ """Broadcast `sending a photo…` for the duration of the upload."""
2169
+ if action is None:
2170
+ yield
2171
+ return
2172
+ client = _client(ctx)
2173
+ try:
2174
+ async with client.action(peer, action):
2175
+ yield
2176
+ except Exception:
2177
+ yield
2178
+
2179
+
2180
+ async def _upload_only(
2181
+ ctx: OpContext, media: Any, kind: str, total_bytes: int, started: float
2182
+ ) -> Uploaded:
2183
+ """`--no-send`: stop after `messages.uploadMedia` and print the file id."""
2184
+ from telethon.tl import types
2185
+ from telethon.tl.functions import messages as fn
2186
+
2187
+ result = await _client(ctx)(fn.UploadMediaRequest(peer=types.InputPeerSelf(), media=media))
2188
+ document = _media.document_of(result) or _media.photo_of(result)
2189
+ return Uploaded(
2190
+ kind=kind,
2191
+ doc_id=int(getattr(document, "id", 0) or 0) or None,
2192
+ file_id=_media.file_id_of(result),
2193
+ bytes=total_bytes,
2194
+ elapsed_s=_elapsed(started),
2195
+ )
2196
+
2197
+
2198
+ async def _reply_to(ctx: OpContext, req: UploadReq) -> Any:
2199
+ return await _send.reply_target(ctx, reply_to=req.reply_to, quote=req.quote, topic=req.topic)
2200
+
2201
+
2202
+ async def _send_one(
2203
+ ctx: OpContext,
2204
+ req: UploadReq,
2205
+ peer: Any,
2206
+ chat_id: int,
2207
+ media: Any,
2208
+ kind: str,
2209
+ started: float,
2210
+ total_bytes: int,
2211
+ ) -> Uploaded:
2212
+ from telethon.tl.functions import messages as fn
2213
+
2214
+ if req.paid_stars is not None:
2215
+ from telethon.tl import types
2216
+
2217
+ media = types.InputMediaPaidMedia(stars_amount=req.paid_stars, extended_media=[media])
2218
+ text, entities = _caption_for(req, 0)
2219
+ result = await _client(ctx)(
2220
+ fn.SendMediaRequest(
2221
+ peer=peer,
2222
+ media=media,
2223
+ message=text,
2224
+ entities=entities or None,
2225
+ reply_to=await _reply_to(ctx, req),
2226
+ silent=req.silent or None,
2227
+ noforwards=req.protect or None,
2228
+ invert_media=req.caption_above or None,
2229
+ clear_draft=req.clear_draft or None,
2230
+ schedule_date=_send.schedule_at(req.schedule),
2231
+ send_as=await _send.resolve(ctx, req.send_as) if req.send_as else None,
2232
+ effect=_send.effect_id(req.effect),
2233
+ random_id=_random_id(),
2234
+ )
2235
+ )
2236
+ message = _send.message_from_updates(result, chat_id=chat_id, sent_text=text)
2237
+ processing = _is_processing(result)
2238
+ if req.wait and processing:
2239
+ ctx.warn("the server is still converting this video; the id may change")
2240
+ ctx.emit("media.sent", {"chat_id": chat_id, "msg_id": message.id, "kind": kind})
2241
+ return Uploaded(
2242
+ chat_id=chat_id,
2243
+ msg_id=message.id,
2244
+ msg_ids=[message.id],
2245
+ kind=kind,
2246
+ file_id=_media.file_id_of(getattr(message, "media", None)),
2247
+ bytes=total_bytes,
2248
+ elapsed_s=_elapsed(started),
2249
+ processing=processing,
2250
+ scheduled_id=message.id if req.schedule else None,
2251
+ )
2252
+
2253
+
2254
+ def _is_processing(updates: Any) -> bool:
2255
+ for update in getattr(updates, "updates", None) or []:
2256
+ if type(update).__name__ == "UpdateMessageExtendedMedia":
2257
+ return True
2258
+ return False
2259
+
2260
+
2261
+ async def _send_album(
2262
+ ctx: OpContext,
2263
+ req: UploadReq,
2264
+ peer: Any,
2265
+ chat_id: int,
2266
+ sources: list[Path],
2267
+ started: float,
2268
+ total_bytes: int,
2269
+ ) -> Uploaded:
2270
+ """2..10 items as one media group.
2271
+
2272
+ Every item goes through `messages.uploadMedia` first: `sendMultiMedia`
2273
+ takes already-uploaded media, and doing it in two steps is also what makes
2274
+ `FILE_REFERENCE_%d_EXPIRED` name the failing index.
2275
+ """
2276
+ from telethon.tl import types
2277
+ from telethon.tl.functions import messages as fn
2278
+
2279
+ if len(sources) > 10:
2280
+ raise UsageError(
2281
+ f"an album holds at most 10 items and {len(sources)} were given", field="path"
2282
+ )
2283
+ if not req.album:
2284
+ raise UsageError(
2285
+ "several files were given but --no-album was set; send them one at a time",
2286
+ field="album",
2287
+ )
2288
+
2289
+ singles: list[Any] = []
2290
+ for index, path in enumerate(sources):
2291
+ media, _ = await _input_media_for(ctx, req, path)
2292
+ uploaded = await _client(ctx)(fn.UploadMediaRequest(peer=peer, media=media))
2293
+ document = _media.document_of(uploaded)
2294
+ photo = _media.photo_of(uploaded) if document is None else None
2295
+ ready: Any = (
2296
+ types.InputMediaDocument(id=_media.input_document(document))
2297
+ if document is not None
2298
+ else types.InputMediaPhoto(id=_media.input_photo(photo))
2299
+ )
2300
+ text, entities = _caption_for(req, index)
2301
+ singles.append(
2302
+ types.InputSingleMedia(
2303
+ media=ready, message=text, entities=entities or None, random_id=_random_id()
2304
+ )
2305
+ )
2306
+
2307
+ result = await _client(ctx)(
2308
+ fn.SendMultiMediaRequest(
2309
+ peer=peer,
2310
+ multi_media=singles,
2311
+ reply_to=await _reply_to(ctx, req),
2312
+ silent=req.silent or None,
2313
+ noforwards=req.protect or None,
2314
+ invert_media=req.caption_above or None,
2315
+ clear_draft=req.clear_draft or None,
2316
+ schedule_date=_send.schedule_at(req.schedule),
2317
+ send_as=await _send.resolve(ctx, req.send_as) if req.send_as else None,
2318
+ effect=_send.effect_id(req.effect),
2319
+ )
2320
+ )
2321
+ messages = _send.messages_from_updates(result, chat_id=chat_id)
2322
+ ids = [message.id for message in messages]
2323
+ ctx.emit("media.sent", {"chat_id": chat_id, "msg_ids": ids, "kind": "album"})
2324
+ return Uploaded(
2325
+ chat_id=chat_id,
2326
+ msg_id=ids[0] if ids else 0,
2327
+ msg_ids=ids,
2328
+ grouped_id=next((m.grouped_id for m in messages if m.grouped_id), None),
2329
+ kind="album",
2330
+ bytes=total_bytes,
2331
+ elapsed_s=_elapsed(started),
2332
+ )
2333
+
2334
+
2335
+ def _random_id() -> int:
2336
+ return int.from_bytes(os.urandom(8), "big", signed=True)
2337
+
2338
+
2339
+ async def _background_upload(ctx: OpContext, req: UploadReq, chat_id: int) -> Uploaded:
2340
+ store = _transfers(ctx)
2341
+ foreground = req.__class__(**{**_as_dict(req), "background": False})
2342
+
2343
+ async def run() -> Any:
2344
+ return await upload(ctx, foreground)
2345
+
2346
+ record = store.submit(
2347
+ direction="upload",
2348
+ name=", ".join(req.path) or "upload",
2349
+ chat_id=chat_id,
2350
+ factory=run,
2351
+ )
2352
+ return Uploaded(chat_id=chat_id, kind="queued", job_id=record.job_id)
2353
+
2354
+
2355
+ SPEC_UPLOAD = OperationSpec(
2356
+ id="media.upload",
2357
+ request=UploadReq,
2358
+ response=Uploaded,
2359
+ impl=upload,
2360
+ summary="Send files as photo, video, document, audio, voice, round video or GIF",
2361
+ description=(
2362
+ "The media half of the composer `message send --file` shares. The kind "
2363
+ "is sniffed from the bytes unless --as says otherwise; attributes are "
2364
+ "probed rather than guessed, because a video sent with duration 0 and "
2365
+ "1x1 dimensions renders as a black rectangle everywhere. Two to ten "
2366
+ "paths become an album, uploaded item by item and sent as one group. "
2367
+ "--no-send stops after messages.uploadMedia and prints a file id the "
2368
+ "same bytes can be re-sent with."
2369
+ ),
2370
+ aliases=("up", "media.send"),
2371
+ legacy_paths=("media upload", "up"),
2372
+ mutating=True,
2373
+ rate_class="file",
2374
+ timeout_s=900,
2375
+ columns=("chat_id", "msg_id", "kind", "bytes"),
2376
+ headers=("Chat", "ID", "Kind", "Bytes"),
2377
+ example={
2378
+ "chat_id": 777123,
2379
+ "msg_id": 12346,
2380
+ "msg_ids": [12346],
2381
+ "kind": "photo",
2382
+ "bytes": 184320,
2383
+ },
2384
+ example_args="media upload @alice photo.jpg --caption 'the cat'",
2385
+ covers=(
2386
+ "emoji.custom-send",
2387
+ "media.big-file-upload",
2388
+ "media.caption-formatting",
2389
+ "media.resend-existing-media",
2390
+ "media.secret-chat-media",
2391
+ "media.send-album",
2392
+ "media.send-audio",
2393
+ "media.send-by-url",
2394
+ "media.send-dice",
2395
+ "media.send-document",
2396
+ "media.send-gif",
2397
+ "media.send-live-photo",
2398
+ "media.send-options-with-media",
2399
+ "media.send-paid-media",
2400
+ "media.send-permission-check",
2401
+ "media.send-photo",
2402
+ "media.send-photo-uncompressed",
2403
+ "media.send-self-destruct",
2404
+ "media.send-spoiler",
2405
+ "media.send-sticker",
2406
+ "media.send-text-as-file",
2407
+ "media.send-thumbnail",
2408
+ "media.send-video",
2409
+ "media.send-video-note",
2410
+ "media.send-video-quality",
2411
+ "media.send-voice-note",
2412
+ "media.streamed-upload",
2413
+ "media.upload-action-broadcast",
2414
+ "media.upload-by-hash",
2415
+ "media.upload-only",
2416
+ "media.video-processing-pending",
2417
+ ),
2418
+ covers_partial=(
2419
+ "media.attached-stickers",
2420
+ "media.background-transfer-jobs",
2421
+ "media.caption-above-media",
2422
+ "media.file-id-export-import",
2423
+ "media.file-reference-refresh",
2424
+ "media.limits-config",
2425
+ "media.parallel-transfer",
2426
+ "media.saved-messages-drive",
2427
+ "media.send-video-cover-and-timestamp",
2428
+ "media.transfer-progress",
2429
+ ),
2430
+ coverage_note=(
2431
+ "Secret-chat peers are refused rather than pretended at: Telethon implements "
2432
+ "no MTProto 2.0 E2E layer. `-` spools stdin to a temp file rather than "
2433
+ "streaming with an unknown part count."
2434
+ ),
2435
+ tags=frozenset({"visible-to-others"}),
2436
+ )
2437
+
2438
+
2439
+ # ---------------------------------------------------------------------------
2440
+ # media edit
2441
+ # ---------------------------------------------------------------------------
2442
+
2443
+
2444
+ class EditReq(Request):
2445
+ chat: Annotated[PeerRef, arg(0, metavar="CHAT", kind="peer", help="Chat.")]
2446
+ msg_id: Annotated[int, arg(1, metavar="MSG_ID", kind="msg_id", help="Message to edit.")]
2447
+ file: Annotated[
2448
+ str | None, opt("--file", metavar="PATH", kind="path", help="Replace with a new upload.")
2449
+ ] = None
2450
+ file_id: Annotated[
2451
+ str | None, opt("--file-id", metavar="ID", help="Replace with media already on Telegram.")
2452
+ ] = None
2453
+ url: Annotated[
2454
+ str | None, opt("--url", metavar="URL", help="Replace with server-fetched media.")
2455
+ ] = None
2456
+ send_as_kind: Annotated[
2457
+ str,
2458
+ choice("auto", "photo", "file", "video", "gif", "audio", help="Kind for the replacement."),
2459
+ ] = "auto"
2460
+ caption: Annotated[
2461
+ str | None, opt("--caption", metavar="TEXT", help="New caption; `-` reads stdin.")
2462
+ ] = None
2463
+ parse: Annotated[str | None, choice("md", "html", "none", help="Caption parse mode.")] = None
2464
+ entities: Annotated[
2465
+ str | None, opt("--entities", metavar="JSON", kind="json", help="Explicit entities.")
2466
+ ] = None
2467
+ caption_above: Annotated[
2468
+ bool | None,
2469
+ opt("--caption-above/--caption-below", help="Move the caption above or below the media."),
2470
+ ] = None
2471
+ spoiler: Annotated[
2472
+ bool | None, opt("--spoiler/--no-spoiler", help="Toggle the blur without re-uploading.")
2473
+ ] = None
2474
+ ttl: Annotated[
2475
+ int | None, opt("--ttl", metavar="SECONDS", kind="duration", help="Change the timer.")
2476
+ ] = None
2477
+ cover: Annotated[str | None, opt("--cover", metavar="PATH", help="Change the video cover.")] = (
2478
+ None
2479
+ )
2480
+ start_at: Annotated[
2481
+ int | None,
2482
+ opt("--start-at", metavar="SECONDS", kind="duration", help="Video start offset."),
2483
+ ] = None
2484
+ paid_stars: Annotated[
2485
+ int | None, opt("--paid-stars", metavar="N", help="Re-price an already-posted paid post.")
2486
+ ] = None
2487
+ thumb: Annotated[
2488
+ str | None, opt("--thumb", metavar="PATH", kind="path", help="New thumbnail.")
2489
+ ] = None
2490
+
2491
+
2492
+ async def edit(ctx: OpContext, req: EditReq) -> MediaEdited:
2493
+ """Replace the media, the caption or a media flag of a sent message.
2494
+
2495
+ Three very different costs hide behind one command. A caption-only or
2496
+ flag-only edit passes the *existing* media back with the one field
2497
+ changed, so no bytes move; `--file` re-uploads; `--file-id`/`--url` do
2498
+ not. Naming that here is what stops a spoiler toggle from costing a
2499
+ 2 GB round trip.
2500
+ """
2501
+ from telethon.tl import types
2502
+ from telethon.tl.functions import messages as fn
2503
+
2504
+ peer = await _send.resolve(ctx, req.chat)
2505
+ chat_id = _send.peer_id_of(peer)
2506
+ message = await _media.fetch_message(ctx, peer, req.msg_id)
2507
+ changed: list[str] = []
2508
+ media: Any = None
2509
+
2510
+ if req.file or req.file_id or req.url:
2511
+ upload_req = UploadReq(
2512
+ chat=req.chat,
2513
+ path=[req.file] if req.file else [],
2514
+ file_id=req.file_id,
2515
+ url=req.url,
2516
+ send_as_kind=req.send_as_kind,
2517
+ thumb=req.thumb,
2518
+ cover=req.cover,
2519
+ start_at=req.start_at,
2520
+ spoiler=bool(req.spoiler),
2521
+ ttl=req.ttl,
2522
+ )
2523
+ media = await _existing_media(ctx, upload_req)
2524
+ if media is None:
2525
+ media, _ = await _input_media_for(
2526
+ ctx, upload_req, Path(os.path.expanduser(str(req.file)))
2527
+ )
2528
+ changed.append("media")
2529
+ elif any(
2530
+ value is not None
2531
+ for value in (req.spoiler, req.ttl, req.cover, req.start_at, req.paid_stars)
2532
+ ):
2533
+ media = _existing_input_media(message, req)
2534
+ changed.append("flags")
2535
+
2536
+ if req.paid_stars is not None:
2537
+ media = types.InputMediaPaidMedia(
2538
+ stars_amount=req.paid_stars, extended_media=[media] if media is not None else []
2539
+ )
2540
+ changed.append("price")
2541
+
2542
+ text: str | None = None
2543
+ entities: list[Any] | None = None
2544
+ if req.caption is not None:
2545
+ parsed, models = _send.body(req.caption, parse=req.parse, entities=req.entities)
2546
+ text, entities = parsed, _send.tl_entities(models)
2547
+ changed.append("caption")
2548
+ if req.caption_above is not None:
2549
+ changed.append("caption_above")
2550
+ if not changed:
2551
+ raise UsageError("nothing to change; give a caption, a file or a flag", field="caption")
2552
+
2553
+ result = await _client(ctx)(
2554
+ fn.EditMessageRequest(
2555
+ peer=peer,
2556
+ id=req.msg_id,
2557
+ media=media,
2558
+ message=text,
2559
+ entities=entities,
2560
+ invert_media=req.caption_above,
2561
+ )
2562
+ )
2563
+ edited = _send.message_from_updates(result, chat_id=chat_id, sent_text=text or "")
2564
+ return MediaEdited(
2565
+ chat_id=chat_id,
2566
+ msg_id=req.msg_id,
2567
+ kind=(edited.media.kind if edited.media is not None else ""),
2568
+ file_id=_media.file_id_of(getattr(message, "media", None)),
2569
+ edit_date=edited.edit_date,
2570
+ caption=edited.text,
2571
+ changed=changed,
2572
+ )
2573
+
2574
+
2575
+ def _existing_input_media(message: Any, req: EditReq) -> Any:
2576
+ """The media that is already there, with one field changed.
2577
+
2578
+ This is the whole trick behind "toggle a spoiler without re-uploading":
2579
+ `editMessage` accepts the existing `InputPhoto`/`InputDocument` back, and
2580
+ the changed flag rides along with it.
2581
+ """
2582
+ from telethon.tl import types
2583
+
2584
+ media = getattr(message, "media", None)
2585
+ document = _media.document_of(media)
2586
+ if document is not None:
2587
+ return types.InputMediaDocument(
2588
+ id=_media.input_document(document),
2589
+ spoiler=req.spoiler,
2590
+ ttl_seconds=req.ttl,
2591
+ video_timestamp=req.start_at,
2592
+ )
2593
+ photo = _media.photo_of(media)
2594
+ if photo is None:
2595
+ raise NotFoundError(f"message {req.msg_id} carries no media to change")
2596
+ return types.InputMediaPhoto(
2597
+ id=_media.input_photo(photo), spoiler=req.spoiler, ttl_seconds=req.ttl
2598
+ )
2599
+
2600
+
2601
+ SPEC_EDIT = OperationSpec(
2602
+ id="media.edit",
2603
+ request=EditReq,
2604
+ response=MediaEdited,
2605
+ impl=edit,
2606
+ summary="Replace the media, caption or media flags of a sent message",
2607
+ mutating=True,
2608
+ rate_class="send",
2609
+ columns=("chat_id", "msg_id", "changed"),
2610
+ headers=("Chat", "ID", "Changed"),
2611
+ example={
2612
+ "chat_id": 777123,
2613
+ "msg_id": 12345,
2614
+ "kind": "photo",
2615
+ "caption": "the cat, again",
2616
+ "changed": ["caption"],
2617
+ },
2618
+ example_args="media edit @alice 12345 --caption 'the cat, again'",
2619
+ covers=(
2620
+ "media.caption-above-media",
2621
+ "media.edit-caption",
2622
+ "media.edit-message-media",
2623
+ "media.edit-spoiler-ttl-without-reupload",
2624
+ "media.paid-media-edit-price",
2625
+ "media.send-video-cover-and-timestamp",
2626
+ ),
2627
+ tags=frozenset({"visible-to-others"}),
2628
+ )
2629
+
2630
+
2631
+ # ---------------------------------------------------------------------------
2632
+ # media export
2633
+ # ---------------------------------------------------------------------------
2634
+
2635
+
2636
+ class ExportReq(Request):
2637
+ chat: Annotated[PeerRef, arg(0, metavar="CHAT", kind="peer", help="Chat to archive.")]
2638
+ out_dir: Annotated[
2639
+ str | None, opt("--out-dir", metavar="DIR", kind="path", help="Destination root.")
2640
+ ] = None
2641
+ type: Annotated[
2642
+ list[str], opt("--type", metavar="TAB", help="Which shared-media tabs to walk.")
2643
+ ] = []
2644
+ from_user: Annotated[
2645
+ PeerRef | None,
2646
+ opt("--from", metavar="USER", kind="user", help="Only media from this sender."),
2647
+ ] = None
2648
+ since: Annotated[
2649
+ str | None, opt("--since", metavar="TS", kind="datetime", help="Start of the window.")
2650
+ ] = None
2651
+ until: Annotated[
2652
+ str | None, opt("--until", metavar="TS", kind="datetime", help="End of the window.")
2653
+ ] = None
2654
+ name_template: Annotated[
2655
+ str, opt("--name-template", metavar="PATTERN", help="Output naming pattern.")
2656
+ ] = DEFAULT_TEMPLATE
2657
+ manifest: Annotated[
2658
+ str | None, opt("--manifest", metavar="PATH", kind="path", help="JSONL manifest path.")
2659
+ ] = None
2660
+ skip_existing: Annotated[
2661
+ bool, opt("--skip-existing", help="Resume: skip what the ledger already has.")
2662
+ ] = True
2663
+ dedupe: Annotated[
2664
+ bool, opt("--dedupe", help="Write each document once and link duplicates.")
2665
+ ] = True
2666
+ max_size: Annotated[
2667
+ str | None, opt("--max-size", metavar="SIZE", help="Skip items larger than this.")
2668
+ ] = None
2669
+ max_items: Annotated[int | None, opt("--max", metavar="N", help="Stop after N items.")] = None
2670
+ connections: Annotated[
2671
+ int, opt("--connections", metavar="N", help="Parallel readers per file.", ge=1, le=8)
2672
+ ] = 1
2673
+ jobs: Annotated[int, opt("--jobs", metavar="N", help="Concurrent files.", ge=1, le=8)] = 2
2674
+ background: Annotated[
2675
+ bool, opt("--background", help="Run in the daemon and return a job id.")
2676
+ ] = True
2677
+
2678
+
2679
+ async def export(ctx: OpContext, req: ExportReq) -> MediaExportResult:
2680
+ """Archive a chat's media with a resumable ledger.
2681
+
2682
+ Always a *plan* first: a big channel is tens of thousands of
2683
+ `upload.getFile` calls, and `--dry-run` answering with the byte total is
2684
+ the difference between an informed export and a surprised one.
2685
+ """
2686
+ import json
2687
+
2688
+ peer = await _send.resolve(ctx, req.chat)
2689
+ chat_id = _send.peer_id_of(peer)
2690
+ root = (
2691
+ Path(os.path.expanduser(req.out_dir))
2692
+ if req.out_dir
2693
+ else _downloads_root(ctx).parent / "export" / str(chat_id)
2694
+ )
2695
+ tabs = list(req.type or ["all"])
2696
+ ceiling = _size_arg(req.max_size, "max_size")
2697
+
2698
+ planned: list[tuple[str, Any]] = []
2699
+ for tab in tabs:
2700
+ kwargs: dict[str, Any] = {"filter": _media.media_filter(tab), "limit": req.max_items or 200}
2701
+ if req.from_user is not None:
2702
+ kwargs["from_user"] = await _send.resolve(ctx, req.from_user)
2703
+ if req.until:
2704
+ kwargs["offset_date"] = parse_dt(req.until)
2705
+ floor = to_unix(parse_dt(req.since)) if req.since else None
2706
+ async for message in _client(ctx).iter_messages(peer, **kwargs):
2707
+ if message is None or getattr(message, "media", None) is None:
2708
+ continue
2709
+ if floor is not None and (to_unix(getattr(message, "date", None)) or 0) < floor:
2710
+ continue
2711
+ planned.append((tab, message))
2712
+
2713
+ result = MediaExportResult(chat_id=chat_id, planned=len(planned), manifest=None)
2714
+ if getattr(ctx, "dry_run", False):
2715
+ result.bytes = sum(
2716
+ int(getattr(_media.document_of(m.media), "size", 0) or 0) for _, m in planned
2717
+ )
2718
+ return result
2719
+
2720
+ ledger_path = root / ".tlgr-export.json"
2721
+ ledger: dict[str, str] = {}
2722
+ if ledger_path.exists():
2723
+ with contextlib.suppress(ValueError, OSError):
2724
+ ledger = json.loads(ledger_path.read_text(encoding="utf-8"))
2725
+ manifest_path = (
2726
+ Path(os.path.expanduser(req.manifest)) if req.manifest else root / "manifest.jsonl"
2727
+ )
2728
+ root.mkdir(parents=True, exist_ok=True)
2729
+
2730
+ with open(manifest_path, "a", encoding="utf-8") as manifest:
2731
+ for tab, message in planned:
2732
+ document = _media.document_of(getattr(message, "media", None))
2733
+ size = int(getattr(document, "size", 0) or 0)
2734
+ key = str(getattr(document, "id", message.id))
2735
+ if ceiling is not None and size > ceiling:
2736
+ result.skipped += 1
2737
+ continue
2738
+ if req.skip_existing and key in ledger and Path(ledger[key]).exists():
2739
+ result.skipped += 1
2740
+ continue
2741
+ date, _ = _media.message_dates(message)
2742
+ name = _safe_name(
2743
+ _media.attributes_of(document).get("file_name") or _default_name(message.media)
2744
+ )
2745
+ target = (
2746
+ root
2747
+ / tab
2748
+ / _fill_template(
2749
+ req.name_template, date=date, message_id=int(message.id), name=name
2750
+ )
2751
+ )
2752
+ try:
2753
+ path = await _download_bytes(
2754
+ ctx,
2755
+ document or _media.photo_of(message.media),
2756
+ target,
2757
+ size=size,
2758
+ dc_id=int(getattr(document, "dc_id", 0) or 0),
2759
+ connections=req.connections,
2760
+ )
2761
+ except Exception as exc: # one bad item must not end the archive
2762
+ result.failed += 1
2763
+ ctx.warn(f"message {message.id}: {exc}")
2764
+ continue
2765
+ ledger[key] = str(path)
2766
+ result.downloaded += 1
2767
+ result.bytes += path.stat().st_size if path.exists() else 0
2768
+ manifest.write(
2769
+ json.dumps(
2770
+ {"msg_id": int(message.id), "type": tab, "path": str(path), "bytes": size}
2771
+ )
2772
+ + "\n"
2773
+ )
2774
+ ledger_path.write_text(json.dumps(ledger, indent=1), encoding="utf-8")
2775
+ result.manifest = str(manifest_path)
2776
+ return result
2777
+
2778
+
2779
+ SPEC_EXPORT = OperationSpec(
2780
+ id="media.export",
2781
+ request=ExportReq,
2782
+ response=MediaExportResult,
2783
+ impl=export,
2784
+ summary="Archive a chat's media to disk with a resumable ledger",
2785
+ description=(
2786
+ "The ledger under the output root maps document id to path, so an "
2787
+ "interrupted export resumes instead of starting again. --dry-run "
2788
+ "answers with the plan and its byte total before anything is fetched."
2789
+ ),
2790
+ rate_class="file",
2791
+ timeout_s=900,
2792
+ columns=("chat_id", "planned", "downloaded", "skipped", "bytes"),
2793
+ headers=("Chat", "Planned", "Got", "Skipped", "Bytes"),
2794
+ example={
2795
+ "chat_id": 777123,
2796
+ "planned": 412,
2797
+ "downloaded": 412,
2798
+ "skipped": 0,
2799
+ "failed": 0,
2800
+ "bytes": 918273645,
2801
+ "manifest": "/home/u/.tlgr/export/777123/manifest.jsonl",
2802
+ },
2803
+ example_args="media export @alice --type photo",
2804
+ covers=("media.download-batch", "media.shared-media-bulk-actions"),
2805
+ )
2806
+
2807
+
2808
+ # ---------------------------------------------------------------------------
2809
+ # media file-id get
2810
+ # ---------------------------------------------------------------------------
2811
+
2812
+
2813
+ class FileIdReq(Request):
2814
+ file_id: Annotated[str, arg(0, metavar="FILE_ID", help="The portable file id.")]
2815
+ source: Annotated[
2816
+ str | None,
2817
+ opt(
2818
+ "--source",
2819
+ metavar="REF",
2820
+ help="Where to re-harvest the reference: `chat:msg-id`, `set:<short-name>` or `gif`.",
2821
+ ),
2822
+ ] = None
2823
+ out: Annotated[
2824
+ str | None, opt("--out", metavar="PATH", kind="path", help="Also download the media.")
2825
+ ] = None
2826
+
2827
+
2828
+ async def file_id_get(ctx: OpContext, req: FileIdReq) -> FileRef:
2829
+ """Resolve a portable file id, refreshing its expired file reference.
2830
+
2831
+ A Bot-API-shaped id carries **no** reference at all, and even a packed one
2832
+ goes stale in hours. The fix is never a retry: it is to re-fetch the
2833
+ object from the source it was seen in and swap the reference, which is
2834
+ what --source names.
2835
+ """
2836
+ from telethon import utils
2837
+
2838
+ try:
2839
+ resolved = utils.resolve_bot_file_id(req.file_id)
2840
+ except Exception as exc:
2841
+ raise UsageError(
2842
+ f"{req.file_id!r} is not a Telegram file id: {exc}", field="file_id"
2843
+ ) from exc
2844
+ if resolved is None:
2845
+ raise UsageError(f"{req.file_id!r} is not a Telegram file id", field="file_id")
2846
+
2847
+ refreshed = False
2848
+ if req.source:
2849
+ resolved = await _reharvest(ctx, req.source) or resolved
2850
+ refreshed = True
2851
+
2852
+ reference = getattr(resolved, "file_reference", b"") or b""
2853
+ ref = FileRef(
2854
+ file_id=req.file_id,
2855
+ doc_id=int(getattr(resolved, "id", 0) or 0),
2856
+ access_hash=getattr(resolved, "access_hash", None),
2857
+ file_reference_b64=_media.b64(reference),
2858
+ dc_id=getattr(resolved, "dc_id", None),
2859
+ kind=_media.kind_of(resolved) if getattr(resolved, "attributes", None) else "photo",
2860
+ mime=getattr(resolved, "mime_type", None),
2861
+ size=getattr(resolved, "size", None),
2862
+ source=req.source,
2863
+ refreshed=refreshed,
2864
+ )
2865
+ if not reference and not refreshed:
2866
+ ctx.warn(
2867
+ "this file id carries no file_reference (Bot-API ids never do); pass --source "
2868
+ "so tlgr can fetch a live one before using it"
2869
+ )
2870
+ if req.out:
2871
+ target = Path(os.path.expanduser(req.out))
2872
+ path = await _download_bytes(
2873
+ ctx,
2874
+ resolved,
2875
+ target,
2876
+ size=int(getattr(resolved, "size", 0) or 0),
2877
+ dc_id=int(getattr(resolved, "dc_id", 0) or 0),
2878
+ )
2879
+ ref.path = str(path)
2880
+ return ref
2881
+
2882
+
2883
+ async def _reharvest(ctx: OpContext, source: str) -> Any:
2884
+ """Fetch the object the id came from, so its reference is live again."""
2885
+ head, _, tail = source.partition(":")
2886
+ if head == "set" and tail:
2887
+ result = await _media.fetch_set(ctx, tail, field="source")
2888
+ documents = list(getattr(result, "documents", None) or [])
2889
+ return documents[0] if documents else None
2890
+ if head == "gif":
2891
+ from telethon.tl.functions import messages as fn
2892
+
2893
+ result = await _client(ctx)(fn.GetSavedGifsRequest(hash=0))
2894
+ gifs = list(getattr(result, "gifs", None) or [])
2895
+ return gifs[0] if gifs else None
2896
+ if tail.lstrip("-").isdigit():
2897
+ from tlgr.models.peer import parse_peer_ref
2898
+
2899
+ peer = await _send.resolve(ctx, parse_peer_ref(head))
2900
+ message = await _media.fetch_message(ctx, peer, int(tail))
2901
+ media = getattr(message, "media", None)
2902
+ return _media.document_of(media) or _media.photo_of(media)
2903
+ raise UsageError("--source takes `chat:msg-id`, `set:<short-name>` or `gif`", field="source")
2904
+
2905
+
2906
+ SPEC_FILE_ID_GET = OperationSpec(
2907
+ id="media.file-id.get",
2908
+ request=FileIdReq,
2909
+ response=FileRef,
2910
+ impl=file_id_get,
2911
+ summary="Resolve a portable file id, refreshing its expired file reference",
2912
+ rate_class="resolve",
2913
+ columns=("file_id", "doc_id", "kind", "refreshed"),
2914
+ headers=("File id", "Doc", "Kind", "Refreshed"),
2915
+ example={
2916
+ "file_id": "CAACAgIAAxkBAAEB",
2917
+ "doc_id": 5312836234,
2918
+ "dc_id": 2,
2919
+ "kind": "sticker",
2920
+ "refreshed": True,
2921
+ "source": "@alice:12345",
2922
+ },
2923
+ example_args="media file-id get CAACAgIAAxkBAAEB --source @alice:12345",
2924
+ covers=("media.file-id-export-import", "media.file-reference-refresh"),
2925
+ )
2926
+
2927
+
2928
+ # ---------------------------------------------------------------------------
2929
+ # media read
2930
+ # ---------------------------------------------------------------------------
2931
+
2932
+
2933
+ class ReadReq(Request):
2934
+ chat: Annotated[PeerRef, arg(0, metavar="CHAT", kind="peer", help="Chat.")]
2935
+ msg_id: Annotated[
2936
+ list[int], arg(1, metavar="MSG_ID", variadic=True, kind="msg_id", help="Message ids.")
2937
+ ] = []
2938
+ listened: Annotated[
2939
+ int | None,
2940
+ opt(
2941
+ "--listened", metavar="SECONDS", kind="duration", help="Report an audio play duration."
2942
+ ),
2943
+ ] = None
2944
+
2945
+
2946
+ async def read(ctx: OpContext, req: ReadReq) -> MediaRead:
2947
+ """Tell the server the media was consumed.
2948
+
2949
+ This is what clears `media_unread`, so the sender's blue dot goes and a
2950
+ self-destruct timer starts. It is irreversible for view-once media, which
2951
+ is exactly why `media download` keeps it behind `--read` instead of doing
2952
+ it implicitly.
2953
+ """
2954
+ peer = await _send.resolve(ctx, req.chat)
2955
+ chat_id = _send.peer_id_of(peer)
2956
+ ids = [int(value) for value in req.msg_id]
2957
+ if not ids:
2958
+ raise UsageError("give at least one message id", field="msg_id")
2959
+ await _mark_read(ctx, peer, ids)
2960
+
2961
+ if req.listened is not None:
2962
+ from telethon.tl.functions import messages as fn
2963
+
2964
+ message = await _media.fetch_message(ctx, peer, ids[0])
2965
+ document = _media.document_of(getattr(message, "media", None))
2966
+ if document is None:
2967
+ raise NotFoundError(f"message {ids[0]} carries no audio document")
2968
+ await _client(ctx)(
2969
+ fn.ReportMusicListenRequest(
2970
+ id=_media.input_document(document), listened_duration=int(req.listened)
2971
+ )
2972
+ )
2973
+ return MediaRead(chat_id=chat_id, msg_ids=ids, marked=len(ids))
2974
+
2975
+
2976
+ SPEC_READ = OperationSpec(
2977
+ id="media.read",
2978
+ request=ReadReq,
2979
+ response=MediaRead,
2980
+ impl=read,
2981
+ summary="Mark media consumed: voice played, view-once opened, track listened",
2982
+ mutating=True,
2983
+ destructive=True,
2984
+ rate_class="send",
2985
+ columns=("chat_id", "msg_ids", "marked"),
2986
+ headers=("Chat", "IDs", "Marked"),
2987
+ example={"chat_id": 777123, "msg_ids": [12345], "marked": 1},
2988
+ example_args="media read @alice 12345",
2989
+ covers=(
2990
+ "media.mark-voice-listened",
2991
+ "media.report-music-listen",
2992
+ "media.view-self-destructing",
2993
+ ),
2994
+ tags=frozenset({"visible-to-others"}),
2995
+ )
2996
+
2997
+
2998
+ # ---------------------------------------------------------------------------
2999
+ # media paid list
3000
+ # ---------------------------------------------------------------------------
3001
+
3002
+
3003
+ class PaidListReq(Request):
3004
+ chat: Annotated[PeerRef, arg(0, metavar="CHAT", kind="peer", help="Channel to read.")]
3005
+ msg_id: Annotated[
3006
+ list[int], opt("--msg-id", metavar="ID", kind="msg_id", help="Refresh these posts' state.")
3007
+ ] = []
3008
+ unlocked: Annotated[bool, opt("--unlocked", help="Only posts already unlocked.")] = False
3009
+ locked: Annotated[bool, opt("--locked", help="Only posts still behind the paywall.")] = False
3010
+ since: Annotated[
3011
+ str | None, opt("--since", metavar="TS", kind="datetime", help="Only at/after this time.")
3012
+ ] = None
3013
+ until: Annotated[
3014
+ str | None, opt("--until", metavar="TS", kind="datetime", help="Only before this time.")
3015
+ ] = None
3016
+
3017
+
3018
+ def _paid_post(message: Any, chat_id: int) -> PaidPost | None:
3019
+ media = getattr(message, "media", None)
3020
+ if type(media).__name__ != "MessageMediaPaidMedia":
3021
+ return None
3022
+ items: list[PaidItem] = []
3023
+ unlocked = False
3024
+ for entry in getattr(media, "extended_media", None) or []:
3025
+ name = type(entry).__name__
3026
+ if name == "MessageExtendedMediaPreview":
3027
+ items.append(
3028
+ PaidItem(
3029
+ kind="preview",
3030
+ width=getattr(entry, "w", None),
3031
+ height=getattr(entry, "h", None),
3032
+ duration=getattr(entry, "video_duration", None),
3033
+ )
3034
+ )
3035
+ continue
3036
+ unlocked = True
3037
+ inner = getattr(entry, "media", None)
3038
+ document = _media.document_of(inner) or _media.photo_of(inner)
3039
+ items.append(PaidItem(kind="media", doc_id=getattr(document, "id", None), unlocked=True))
3040
+ date, date_unix = _media.message_dates(message)
3041
+ return PaidPost(
3042
+ chat_id=chat_id,
3043
+ msg_id=int(getattr(message, "id", 0) or 0),
3044
+ stars_amount=int(getattr(media, "stars_amount", 0) or 0),
3045
+ item_count=len(items),
3046
+ unlocked=unlocked,
3047
+ items=items,
3048
+ date=date,
3049
+ date_unix=date_unix,
3050
+ caption=getattr(message, "message", "") or "",
3051
+ )
3052
+
3053
+
3054
+ async def paid_list(ctx: OpContext, req: PaidListReq) -> Page[PaidPost]:
3055
+ """Paid-media posts and their unlock state.
3056
+
3057
+ Read-only by design: buying is a payment, and tlgr does not spend Stars on
3058
+ the user's behalf. `updateMessageExtendedMedia` carries no `pts`, so a
3059
+ client that was offline learns about a purchase made elsewhere only by
3060
+ asking — which is what `--msg-id` does.
3061
+ """
3062
+ limit, state = _media.window(ctx, "media.paid.list", PageKind.SEARCH)
3063
+ peer = await _send.resolve(ctx, req.chat)
3064
+ chat_id = _send.peer_id_of(peer)
3065
+
3066
+ if req.msg_id:
3067
+ from telethon.tl.functions import messages as fn
3068
+
3069
+ result = await _client(ctx)(
3070
+ fn.GetExtendedMediaRequest(peer=peer, id=[int(i) for i in req.msg_id])
3071
+ )
3072
+ found = _send.messages_from_updates(result, chat_id=chat_id)
3073
+ refreshed = await _client(ctx).get_messages(peer, ids=[int(i) for i in req.msg_id])
3074
+ one_by_one: list[PaidPost] = [
3075
+ post
3076
+ for message in (refreshed or [])
3077
+ if message is not None and (post := _paid_post(message, chat_id)) is not None
3078
+ ]
3079
+ _ = found
3080
+ return Page(items=one_by_one, has_more=False, total=len(one_by_one))
3081
+
3082
+ posts: list[PaidPost] = []
3083
+ floor = to_unix(parse_dt(req.since)) if req.since else None
3084
+ kwargs: dict[str, Any] = {"limit": limit, "offset_id": int(state.get("offset_id", 0))}
3085
+ if req.until:
3086
+ kwargs["offset_date"] = parse_dt(req.until)
3087
+ async for message in _client(ctx).iter_messages(peer, **kwargs):
3088
+ if message is None:
3089
+ continue
3090
+ post = _paid_post(message, chat_id)
3091
+ if post is None:
3092
+ continue
3093
+ if floor is not None and post.date_unix < floor:
3094
+ continue
3095
+ if req.unlocked and not post.unlocked:
3096
+ continue
3097
+ if req.locked and post.unlocked:
3098
+ continue
3099
+ posts.append(post)
3100
+
3101
+ next_state = {"offset_id": posts[-1].msg_id} if posts else {}
3102
+ return build_page(
3103
+ posts,
3104
+ op="media.paid.list",
3105
+ kind=PageKind.SEARCH,
3106
+ state=next_state,
3107
+ account=ctx.account,
3108
+ limit=limit,
3109
+ has_more=None if posts else False,
3110
+ )
3111
+
3112
+
3113
+ SPEC_PAID_LIST = OperationSpec(
3114
+ id="media.paid.list",
3115
+ request=PaidListReq,
3116
+ response=Page[PaidPost],
3117
+ impl=paid_list,
3118
+ summary="Paid-media posts and their unlock state",
3119
+ description=(
3120
+ "Buying is deliberately absent: spending Stars is a payment tlgr will "
3121
+ "not execute for you. Posting is `media upload --paid-stars N` and "
3122
+ "re-pricing is `media edit --paid-stars N`."
3123
+ ),
3124
+ paginated=PageKind.SEARCH,
3125
+ columns=("msg_id", "stars_amount", "item_count", "unlocked"),
3126
+ headers=("ID", "Stars", "Items", "Unlocked"),
3127
+ example={
3128
+ "items": [
3129
+ {
3130
+ "chat_id": -1000000123,
3131
+ "msg_id": 890,
3132
+ "stars_amount": 50,
3133
+ "item_count": 3,
3134
+ "unlocked": False,
3135
+ }
3136
+ ],
3137
+ "has_more": False,
3138
+ },
3139
+ example_args="media paid list @channel",
3140
+ covers=("media.paid-media-inspect",),
3141
+ )
3142
+
3143
+
3144
+ # ---------------------------------------------------------------------------
3145
+ # media limit get
3146
+ # ---------------------------------------------------------------------------
3147
+
3148
+
3149
+ class LimitReq(Request):
3150
+ refresh: Annotated[bool, opt("--refresh", help="Bypass the cached help.getAppConfig hash.")] = (
3151
+ False
3152
+ )
3153
+
3154
+
3155
+ async def limit_get(ctx: OpContext, req: LimitReq) -> MediaLimits:
3156
+ """The server's own media limits. Never hardcode one of these.
3157
+
3158
+ `media upload` reads the same numbers before the first part goes out, so
3159
+ an oversized file is a named refusal rather than `FILE_PARTS_INVALID`
3160
+ after the bandwidth has been spent.
3161
+ """
3162
+ values = await _media.app_config(ctx)
3163
+ premium = _is_premium(ctx)
3164
+ suffix = "premium" if premium else "default"
3165
+
3166
+ def number(name: str, fallback: int = 0) -> int:
3167
+ return _media.config_int(
3168
+ values, f"{name}_{suffix}", _media.config_int(values, name, fallback)
3169
+ )
3170
+
3171
+ part_size = 512 * 1024
3172
+ parts = number("upload_max_fileparts", 4000)
3173
+ return MediaLimits(
3174
+ premium=premium,
3175
+ upload_max_fileparts=parts,
3176
+ upload_max_bytes=parts * part_size,
3177
+ part_size=part_size,
3178
+ caption_length_limit=number("caption_length_limit", 1024),
3179
+ message_length_limit=number("message_length_limit", 4096),
3180
+ album_size_max=_media.config_int(values, "album_size_max", 10),
3181
+ stickers_installed_limit=number("stickers_installed_limit", 200),
3182
+ stickers_faved_limit=number("stickers_faved_limit", 5),
3183
+ stickers_recent_limit=_media.config_int(values, "stickers_recent_limit", 20),
3184
+ saved_gifs_limit=number("saved_gifs_limit", 200),
3185
+ ringtone_size_max=_media.config_int(values, "ringtone_size_max", 307200),
3186
+ ringtone_duration_max=_media.config_int(values, "ringtone_duration_max", 5),
3187
+ stars_paid_post_amount_max=_media.config_int(values, "stars_paid_post_amount_max", 0),
3188
+ premium_speedup_upload=_media.config_int(values, "upload_premium_speedup_upload", 0),
3189
+ premium_speedup_download=_media.config_int(values, "upload_premium_speedup_download", 0),
3190
+ )
3191
+
3192
+
3193
+ SPEC_LIMIT_GET = OperationSpec(
3194
+ id="media.limit.get",
3195
+ request=LimitReq,
3196
+ response=MediaLimits,
3197
+ impl=limit_get,
3198
+ summary="Server-side media limits for this account",
3199
+ columns=("premium", "upload_max_bytes", "caption_length_limit", "album_size_max"),
3200
+ headers=("Premium", "Max upload", "Caption", "Album"),
3201
+ example={
3202
+ "premium": False,
3203
+ "upload_max_fileparts": 4000,
3204
+ "upload_max_bytes": 2097152000,
3205
+ "part_size": 524288,
3206
+ "caption_length_limit": 1024,
3207
+ "album_size_max": 10,
3208
+ },
3209
+ example_args="media limit get",
3210
+ covers=("media.limits-config",),
3211
+ )
3212
+
3213
+
3214
+ # ---------------------------------------------------------------------------
3215
+ # media transfer
3216
+ # ---------------------------------------------------------------------------
3217
+
3218
+
3219
+ class TransferListReq(Request):
3220
+ active: Annotated[bool, opt("--active", help="Only running transfers.")] = False
3221
+ failed: Annotated[bool, opt("--failed", help="Only failed transfers.")] = False
3222
+ watch: Annotated[
3223
+ bool, opt("--watch", help="Follow progress until every listed transfer settles.")
3224
+ ] = False
3225
+
3226
+
3227
+ async def transfer_list(ctx: OpContext, req: TransferListReq) -> Page[Transfer]:
3228
+ """The Downloads panel: client-local state, exactly as it is in the GUI.
3229
+
3230
+ There is no server-side downloads list to read; a transfer exists because
3231
+ this daemon started it.
3232
+ """
3233
+ limit, _state = _media.window(ctx, "media.transfer.list", PageKind.LOCAL)
3234
+ store = _transfers(ctx)
3235
+ if req.watch:
3236
+ await store.settle(timeout=30.0)
3237
+ items = [
3238
+ Transfer(**record)
3239
+ for record in store.snapshot(active=req.active, failed=req.failed)[:limit]
3240
+ ]
3241
+ return Page(items=items, has_more=False, total=len(items))
3242
+
3243
+
3244
+ SPEC_TRANSFER_LIST = OperationSpec(
3245
+ id="media.transfer.list",
3246
+ request=TransferListReq,
3247
+ response=Page[Transfer],
3248
+ impl=transfer_list,
3249
+ summary="Active and recent uploads and downloads",
3250
+ paginated=PageKind.LOCAL,
3251
+ columns=("job_id", "direction", "name", "pct", "state"),
3252
+ headers=("Job", "Way", "Name", "%", "State"),
3253
+ example={
3254
+ "items": [
3255
+ {
3256
+ "job_id": "9f2c1a",
3257
+ "direction": "download",
3258
+ "name": "cat.mp4",
3259
+ "bytes_done": 1048576,
3260
+ "bytes_total": 8412300,
3261
+ "pct": 12.5,
3262
+ "state": "running",
3263
+ }
3264
+ ],
3265
+ "has_more": False,
3266
+ },
3267
+ example_args="media transfer list --active",
3268
+ covers=("media.downloads-list", "media.transfer-progress"),
3269
+ covers_partial=("media.background-transfer-jobs",),
3270
+ coverage_note="Retrying and cancelling are `media transfer retry` and `media transfer stop`.",
3271
+ )
3272
+
3273
+
3274
+ class TransferStopReq(Request):
3275
+ job_id: Annotated[
3276
+ list[str],
3277
+ arg(0, metavar="JOB_ID", required=False, variadic=True, help="Transfers to stop."),
3278
+ ] = []
3279
+ every: Annotated[bool, opt("--all", help="Cancel every running transfer.")] = False
3280
+ keep_partial: Annotated[
3281
+ bool, opt("--keep-partial/--discard-partial", help="Keep the .part file for --resume.")
3282
+ ] = True
3283
+
3284
+
3285
+ async def transfer_stop(ctx: OpContext, req: TransferStopReq) -> TransferStopped:
3286
+ """Stop an in-flight transfer.
3287
+
3288
+ A download stops cleanly between chunks and leaves its `.part` file, so
3289
+ `--resume` continues it. An upload cannot be resumed at all — saved parts
3290
+ expire server-side — so a cancelled upload is restarted with a fresh file
3291
+ id rather than continued.
3292
+ """
3293
+ store = _transfers(ctx)
3294
+ ids = list(req.job_id)
3295
+ if req.every:
3296
+ ids = [record["job_id"] for record in store.snapshot(active=True)]
3297
+ if not ids:
3298
+ raise UsageError("name a transfer, or pass --all", field="job_id")
3299
+ cancelled = await store.cancel(ids, keep_partial=req.keep_partial)
3300
+ return TransferStopped(
3301
+ job_ids=ids,
3302
+ cancelled=cancelled,
3303
+ kept_partial=req.keep_partial,
3304
+ already=cancelled == 0,
3305
+ )
3306
+
3307
+
3308
+ SPEC_TRANSFER_STOP = OperationSpec(
3309
+ id="media.transfer.stop",
3310
+ request=TransferStopReq,
3311
+ response=TransferStopped,
3312
+ impl=transfer_stop,
3313
+ summary="Stop an in-flight upload or download",
3314
+ aliases=("media.transfer.cancel",),
3315
+ mutating=True,
3316
+ destructive=True,
3317
+ columns=("job_ids", "cancelled", "kept_partial"),
3318
+ headers=("Jobs", "Cancelled", "Kept"),
3319
+ example={"job_ids": ["9f2c1a"], "cancelled": 1, "kept_partial": True},
3320
+ example_args="media transfer stop 9f2c1a",
3321
+ covers=("media.transfer-cancel",),
3322
+ covers_partial=("media.background-transfer-jobs",),
3323
+ coverage_note="The job store lives in the daemon; `media transfer list` is its view.",
3324
+ )
3325
+
3326
+
3327
+ class TransferRetryReq(Request):
3328
+ job_id: Annotated[
3329
+ list[str],
3330
+ arg(0, metavar="JOB_ID", required=False, variadic=True, help="Transfers to restart."),
3331
+ ] = []
3332
+ every: Annotated[bool, opt("--all", help="Retry every failed transfer.")] = False
3333
+ from_scratch: Annotated[
3334
+ bool, opt("--from-scratch", help="Ignore the partial file and start at byte 0.")
3335
+ ] = False
3336
+
3337
+
3338
+ async def transfer_retry(ctx: OpContext, req: TransferRetryReq) -> TransferRestarted:
3339
+ """Restart a failed or cancelled transfer.
3340
+
3341
+ The source is re-fetched first: a transfer that sat in the failed queue
3342
+ for an hour is holding an expired `file_reference`, and retrying with the
3343
+ stale one fails identically.
3344
+ """
3345
+ store = _transfers(ctx)
3346
+ ids = list(req.job_id)
3347
+ if req.every:
3348
+ ids = [record["job_id"] for record in store.snapshot(failed=True)]
3349
+ if not ids:
3350
+ raise UsageError("name a transfer, or pass --all", field="job_id")
3351
+ restarted, resumed_from = store.retry(ids, from_scratch=req.from_scratch)
3352
+ return TransferRestarted(job_ids=ids, restarted=restarted, resumed_from=resumed_from)
3353
+
3354
+
3355
+ SPEC_TRANSFER_RETRY = OperationSpec(
3356
+ id="media.transfer.retry",
3357
+ request=TransferRetryReq,
3358
+ response=TransferRestarted,
3359
+ impl=transfer_retry,
3360
+ summary="Restart a failed or cancelled transfer",
3361
+ mutating=True,
3362
+ rate_class="file",
3363
+ columns=("job_ids", "restarted", "resumed_from"),
3364
+ headers=("Jobs", "Restarted", "From"),
3365
+ example={"job_ids": ["9f2c1a"], "restarted": 1, "resumed_from": 1048576},
3366
+ example_args="media transfer retry 9f2c1a",
3367
+ covers=("media.background-transfer-jobs",),
3368
+ )
3369
+
3370
+
3371
+ # ---------------------------------------------------------------------------
3372
+ # media watch
3373
+ # ---------------------------------------------------------------------------
3374
+
3375
+
3376
+ class WatchReq(Request):
3377
+ chat: Annotated[
3378
+ list[PeerRef],
3379
+ arg(0, metavar="CHAT", required=False, variadic=True, kind="peer", help="Chats to watch."),
3380
+ ] = []
3381
+ type: Annotated[list[str], opt("--type", metavar="KIND", help="Media kinds to report.")] = []
3382
+ download: Annotated[
3383
+ str | None,
3384
+ opt("--download", metavar="DIR", kind="path", help="Auto-download matching media there."),
3385
+ ] = None
3386
+ max_size: Annotated[
3387
+ str | None, opt("--max-size", metavar="SIZE", help="Skip auto-download above this size.")
3388
+ ] = None
3389
+ use_cloud_settings: Annotated[
3390
+ bool, opt("--use-cloud-settings", help="Honour the account's auto-download preset.")
3391
+ ] = False
3392
+ from_user: Annotated[
3393
+ PeerRef | None,
3394
+ opt("--from", metavar="USER", kind="user", help="Only media from this sender."),
3395
+ ] = None
3396
+ min_duration: Annotated[
3397
+ int | None,
3398
+ opt("--min-duration", metavar="SECONDS", kind="duration", help="Only longer audio/video."),
3399
+ ] = None
3400
+
3401
+
3402
+ async def watch(ctx: OpContext, req: WatchReq) -> Any:
3403
+ """Stream incoming media events, optionally downloading them.
3404
+
3405
+ A media-shaped view of the daemon's existing update bus, not a second
3406
+ update loop: the daemon already holds the client and the MessageBox
3407
+ state, and this subscribes to it. Service messages never reach
3408
+ `events.NewMessage`, so a changed chat photo is not reported here —
3409
+ `media list --type chat-photo` is.
3410
+ """
3411
+ bus = getattr(ctx, "bus", None)
3412
+ if bus is None:
3413
+ raise NotSupportedError("this build has no event bus to watch")
3414
+
3415
+ chats: list[int] = []
3416
+ for reference in req.chat:
3417
+ chats.append(_send.peer_id_of(await _send.resolve(ctx, reference)))
3418
+ ceiling = _size_arg(req.max_size, "max_size")
3419
+ if req.use_cloud_settings and ceiling is None:
3420
+ ceiling = await _cloud_ceiling(ctx)
3421
+ wanted = {kind for kind in req.type if kind != "all"}
3422
+ sender = _send.peer_id_of(await _send.resolve(ctx, req.from_user)) if req.from_user else None
3423
+
3424
+ subscriber = bus.subscribe(ctx.account, types=("message_new",), chats=chats)
3425
+ try:
3426
+ while True:
3427
+ event = await subscriber.queue.get()
3428
+ frame = _media_event(event, wanted, sender, req.min_duration)
3429
+ if frame is None:
3430
+ continue
3431
+ if req.download and (ceiling is None or (frame.size or 0) <= ceiling):
3432
+ frame.path = await _watch_download(ctx, event, req)
3433
+ yield Page(items=[frame], has_more=True)
3434
+ finally:
3435
+ bus.unsubscribe(subscriber)
3436
+
3437
+
3438
+ def _media_event(
3439
+ event: Any, wanted: set[str], sender: int | None, min_duration: int | None
3440
+ ) -> MediaEvent | None:
3441
+ payload = getattr(event, "payload", None) or {}
3442
+ media = payload.get("media")
3443
+ if not isinstance(media, dict):
3444
+ return None
3445
+ kind = str(media.get("kind") or "")
3446
+ if wanted and kind not in wanted:
3447
+ return None
3448
+ if sender is not None and payload.get("sender_id") != sender:
3449
+ return None
3450
+ duration = media.get("duration")
3451
+ if min_duration is not None and int(duration or 0) < min_duration:
3452
+ return None
3453
+ return MediaEvent(
3454
+ event="media",
3455
+ chat_id=int(payload.get("chat_id") or event.chat_id or 0),
3456
+ msg_id=int(payload.get("id") or 0),
3457
+ kind=kind,
3458
+ mime=media.get("mime_type"),
3459
+ size=media.get("size"),
3460
+ duration=duration,
3461
+ from_id=payload.get("sender_id"),
3462
+ date=str(payload.get("date") or ""),
3463
+ )
3464
+
3465
+
3466
+ async def _cloud_ceiling(ctx: OpContext) -> int | None:
3467
+ """The account's own auto-download cap, as the size limit."""
3468
+ settings = await auto_download_get(ctx, AutoDownloadGetReq(preset="medium"))
3469
+ for preset in settings.presets:
3470
+ if preset.preset == "medium":
3471
+ return max(preset.photo_size_max, preset.video_size_max, preset.file_size_max) or None
3472
+ return None
3473
+
3474
+
3475
+ async def _watch_download(ctx: OpContext, event: Any, req: WatchReq) -> str | None:
3476
+ payload = getattr(event, "payload", None) or {}
3477
+ chat_id = int(payload.get("chat_id") or 0)
3478
+ message_id = int(payload.get("id") or 0)
3479
+ if not chat_id or not message_id:
3480
+ return None
3481
+ try:
3482
+ message = await _media.fetch_message(ctx, chat_id, message_id)
3483
+ document = _media.document_of(message.media) or _media.photo_of(message.media)
3484
+ target = Path(os.path.expanduser(str(req.download))) / _safe_name(
3485
+ _media.attributes_of(document).get("file_name") or _default_name(message.media)
3486
+ )
3487
+ path = await _download_bytes(
3488
+ ctx,
3489
+ document,
3490
+ target,
3491
+ size=int(getattr(document, "size", 0) or 0),
3492
+ dc_id=int(getattr(document, "dc_id", 0) or 0),
3493
+ )
3494
+ return str(path)
3495
+ except Exception as exc: # a failed auto-download must not end the stream
3496
+ ctx.warn(f"auto-download of {message_id} failed: {exc}")
3497
+ return None
3498
+
3499
+
3500
+ SPEC_WATCH = OperationSpec(
3501
+ id="media.watch",
3502
+ request=WatchReq,
3503
+ response=Page[MediaEvent],
3504
+ impl=watch,
3505
+ summary="Stream incoming media events and optionally auto-download them",
3506
+ stream=True,
3507
+ timeout_s=900,
3508
+ columns=("chat_id", "msg_id", "kind", "size"),
3509
+ headers=("Chat", "ID", "Kind", "Size"),
3510
+ example={
3511
+ "items": [
3512
+ {
3513
+ "event": "media",
3514
+ "chat_id": 777123,
3515
+ "msg_id": 12347,
3516
+ "kind": "photo",
3517
+ "size": 184320,
3518
+ }
3519
+ ],
3520
+ "has_more": True,
3521
+ },
3522
+ example_args="media watch @alice --type photo",
3523
+ covers=("media.receive-event-watch",),
3524
+ )
3525
+
3526
+
3527
+ # ---------------------------------------------------------------------------
3528
+ # media wallpaper
3529
+ # ---------------------------------------------------------------------------
3530
+
3531
+
3532
+ def _colour(value: str) -> int:
3533
+ text = value.strip().lstrip("#")
3534
+ try:
3535
+ return int(text, 16)
3536
+ except ValueError as exc:
3537
+ raise UsageError(f"{value!r} is not a hex colour", field="colors") from exc
3538
+
3539
+
3540
+ def _colour_text(value: int | None) -> str | None:
3541
+ return f"#{value:06x}" if isinstance(value, int) else None
3542
+
3543
+
3544
+ def _wallpaper_settings(settings: Any) -> WallpaperSettings:
3545
+ colours = [
3546
+ _colour_text(getattr(settings, name, None))
3547
+ for name in (
3548
+ "background_color",
3549
+ "second_background_color",
3550
+ "third_background_color",
3551
+ "fourth_background_color",
3552
+ )
3553
+ ]
3554
+ return WallpaperSettings(
3555
+ blur=bool(getattr(settings, "blur", False)),
3556
+ motion=bool(getattr(settings, "motion", False)),
3557
+ intensity=getattr(settings, "intensity", None),
3558
+ rotation=getattr(settings, "rotation", None),
3559
+ colors=[c for c in colours if c],
3560
+ )
3561
+
3562
+
3563
+ def _wallpaper_model(raw: Any) -> Wallpaper:
3564
+ settings = getattr(raw, "settings", None)
3565
+ parsed = _wallpaper_settings(settings) if settings is not None else WallpaperSettings()
3566
+ document = getattr(raw, "document", None)
3567
+ slug = getattr(raw, "slug", None)
3568
+ pattern = bool(getattr(raw, "pattern", False))
3569
+ kind: Literal["image", "pattern", "fill"] = (
3570
+ "fill" if document is None else ("pattern" if pattern else "image")
3571
+ )
3572
+ return Wallpaper(
3573
+ id=int(getattr(raw, "id", 0) or 0),
3574
+ access_hash=getattr(raw, "access_hash", None),
3575
+ slug=slug,
3576
+ kind=kind,
3577
+ pattern=pattern,
3578
+ dark=bool(getattr(raw, "dark", False)),
3579
+ creator=bool(getattr(raw, "creator", False)),
3580
+ default=bool(getattr(raw, "default", False)),
3581
+ colors=parsed.colors,
3582
+ blur=parsed.blur,
3583
+ motion=parsed.motion,
3584
+ intensity=parsed.intensity,
3585
+ rotation=parsed.rotation,
3586
+ document=_media.media_file(document),
3587
+ link=_wallpaper_link(slug, parsed),
3588
+ )
3589
+
3590
+
3591
+ def _wallpaper_link(slug: str | None, settings: WallpaperSettings) -> str | None:
3592
+ """`t.me/bg/<slug>?…` — string formatting, never a request."""
3593
+ if not slug:
3594
+ return None
3595
+ modes = [name for name, on in (("blur", settings.blur), ("motion", settings.motion)) if on]
3596
+ query: list[str] = []
3597
+ if modes:
3598
+ query.append("mode=" + "+".join(modes))
3599
+ if settings.intensity is not None:
3600
+ query.append(f"intensity={settings.intensity}")
3601
+ if settings.colors:
3602
+ query.append("bg_color=" + "-".join(c.lstrip("#") for c in settings.colors))
3603
+ if settings.rotation is not None:
3604
+ query.append(f"rotation={settings.rotation}")
3605
+ return f"https://t.me/bg/{slug}" + ("?" + "&".join(query) if query else "")
3606
+
3607
+
3608
+ def _wallpaper_ref(text: str) -> Any:
3609
+ from telethon.tl import types
3610
+
3611
+ value = (text or "").strip()
3612
+ if "t.me/bg/" in value:
3613
+ value = value.split("t.me/bg/", 1)[1].split("?")[0]
3614
+ if value.lstrip("-").isdigit():
3615
+ raise UsageError(
3616
+ f"{value!r} is a wallpaper id without its access hash; name it by slug or "
3617
+ "by its t.me/bg link",
3618
+ field="wallpaper",
3619
+ )
3620
+ return types.InputWallPaperSlug(slug=value)
3621
+
3622
+
3623
+ class WallpaperListReq(Request):
3624
+ saved: Annotated[bool, opt("--saved", help="Only the ones saved to the account.")] = False
3625
+ default: Annotated[bool, opt("--default", help="Only the preinstalled gallery.")] = False
3626
+ patterns: Annotated[bool, opt("--patterns", help="Only pattern and fill wallpapers.")] = False
3627
+
3628
+
3629
+ async def wallpaper_list(ctx: OpContext, req: WallpaperListReq) -> Page[Wallpaper]:
3630
+ """The account's wallpapers.
3631
+
3632
+ A hash-cached list, not an offset-paginated one: the server answers
3633
+ `wallPapersNotModified` when nothing changed, so there are no pages to
3634
+ walk and `--limit` only trims what came back.
3635
+ """
3636
+ from telethon.tl.functions import account as fn
3637
+
3638
+ limit, _state = _media.window(ctx, "media.wallpaper.list", PageKind.LOCAL)
3639
+ result = await _client(ctx)(fn.GetWallPapersRequest(hash=0))
3640
+ items = [_wallpaper_model(raw) for raw in getattr(result, "wallpapers", None) or []]
3641
+ if req.saved:
3642
+ items = [item for item in items if not item.default]
3643
+ if req.default:
3644
+ items = [item for item in items if item.default]
3645
+ if req.patterns:
3646
+ items = [item for item in items if item.kind in ("pattern", "fill")]
3647
+ return Page(items=items[:limit], has_more=False, total=len(items))
3648
+
3649
+
3650
+ SPEC_WALLPAPER_LIST = OperationSpec(
3651
+ id="media.wallpaper.list",
3652
+ request=WallpaperListReq,
3653
+ response=Page[Wallpaper],
3654
+ impl=wallpaper_list,
3655
+ summary="Wallpapers available to this account",
3656
+ paginated=PageKind.LOCAL,
3657
+ columns=("slug", "kind", "dark", "colors"),
3658
+ headers=("Slug", "Kind", "Dark", "Colours"),
3659
+ example={
3660
+ "items": [{"id": 5947530738857476, "slug": "Ycb0FfC6", "kind": "pattern", "dark": False}],
3661
+ "has_more": False,
3662
+ },
3663
+ example_args="media wallpaper list",
3664
+ covers=("dialogs.wallpaper-gallery", "wallpaper.list"),
3665
+ )
3666
+
3667
+
3668
+ class WallpaperGetReq(Request):
3669
+ wallpaper: Annotated[
3670
+ str, arg(0, metavar="WALLPAPER", help="Slug, or a https://t.me/bg/<slug> link.")
3671
+ ]
3672
+ out: Annotated[
3673
+ str | None, opt("--out", metavar="PATH", kind="path", help="Download the image.")
3674
+ ] = None
3675
+ link: Annotated[bool, opt("--link", help="Print only the shareable link.")] = False
3676
+
3677
+
3678
+ async def wallpaper_get(ctx: OpContext, req: WallpaperGetReq) -> Wallpaper:
3679
+ """One wallpaper, and the share link its settings encode."""
3680
+ from telethon.tl.functions import account as fn
3681
+
3682
+ result = await _client(ctx)(fn.GetWallPaperRequest(wallpaper=_wallpaper_ref(req.wallpaper)))
3683
+ model = _wallpaper_model(result)
3684
+ if req.out and model.document is not None:
3685
+ document = getattr(result, "document", None)
3686
+ path = await _download_bytes(
3687
+ ctx,
3688
+ document,
3689
+ Path(os.path.expanduser(req.out)),
3690
+ size=int(getattr(document, "size", 0) or 0),
3691
+ dc_id=int(getattr(document, "dc_id", 0) or 0),
3692
+ )
3693
+ model.document.file_id = model.document.file_id or str(path)
3694
+ return model
3695
+
3696
+
3697
+ SPEC_WALLPAPER_GET = OperationSpec(
3698
+ id="media.wallpaper.get",
3699
+ request=WallpaperGetReq,
3700
+ response=Wallpaper,
3701
+ impl=wallpaper_get,
3702
+ summary="One wallpaper by slug or t.me/bg link",
3703
+ columns=("slug", "kind", "colors", "link"),
3704
+ headers=("Slug", "Kind", "Colours", "Link"),
3705
+ empty_exit=EXIT_EMPTY,
3706
+ example={
3707
+ "id": 5947530738857476,
3708
+ "slug": "Ycb0FfC6",
3709
+ "kind": "pattern",
3710
+ "colors": ["#dbddbb", "#6ba587"],
3711
+ "link": "https://t.me/bg/Ycb0FfC6?intensity=50",
3712
+ },
3713
+ example_args="media wallpaper get Ycb0FfC6",
3714
+ covers=("wallpaper.share-link",),
3715
+ covers_partial=("wallpaper.list",),
3716
+ coverage_note="The gallery itself is `media wallpaper list`.",
3717
+ )
3718
+
3719
+
3720
+ class WallpaperSetReq(Request):
3721
+ wallpaper: Annotated[
3722
+ str | None,
3723
+ arg(0, metavar="WALLPAPER", required=False, help="Slug or link; omit for a colour fill."),
3724
+ ] = None
3725
+ blur: Annotated[bool, opt("--blur", help="Apply blurred.")] = False
3726
+ motion: Annotated[bool, opt("--motion", help="Apply with parallax motion.")] = False
3727
+ intensity: Annotated[
3728
+ int | None, opt("--intensity", metavar="N", help="Pattern intensity, -100..100.")
3729
+ ] = None
3730
+ colors: Annotated[list[str], opt("--colors", metavar="HEX", help="1-4 hex fill colours.")] = []
3731
+ save_only: Annotated[
3732
+ bool, opt("--save-only", help="Add to the saved list without installing it.")
3733
+ ] = False
3734
+ reset: Annotated[bool, opt("--reset", help="Restore the server's default list.")] = False
3735
+
3736
+
3737
+ def _settings_from(req: WallpaperSetReq) -> Any:
3738
+ from telethon.tl import types
3739
+
3740
+ colours = [_colour(value) for value in req.colors][:4]
3741
+ padded = colours + [None] * (4 - len(colours))
3742
+ return types.WallPaperSettings(
3743
+ blur=req.blur or None,
3744
+ motion=req.motion or None,
3745
+ background_color=padded[0],
3746
+ second_background_color=padded[1],
3747
+ third_background_color=padded[2],
3748
+ fourth_background_color=padded[3],
3749
+ intensity=req.intensity,
3750
+ )
3751
+
3752
+
3753
+ async def wallpaper_set(ctx: OpContext, req: WallpaperSetReq) -> WallpaperInstalled:
3754
+ """Make a wallpaper the account default.
3755
+
3756
+ Only the cloud *choice* is synced; rendering is a client concern a
3757
+ terminal has no use for, so tlgr syncs the selection and stops there.
3758
+ """
3759
+ from telethon.tl import types
3760
+ from telethon.tl.functions import account as fn
3761
+
3762
+ if req.reset:
3763
+ await _client(ctx)(fn.ResetWallPapersRequest())
3764
+ return WallpaperInstalled(reset=True, installed=False, saved=False)
3765
+
3766
+ settings = _settings_from(req)
3767
+ if req.wallpaper:
3768
+ reference: Any = _wallpaper_ref(req.wallpaper)
3769
+ elif req.colors:
3770
+ # A pure colour or gradient is a wallpaper with no file at all.
3771
+ reference = types.InputWallPaperNoFile(id=0)
3772
+ else:
3773
+ raise UsageError("name a wallpaper, or give --colors for a fill", field="wallpaper")
3774
+
3775
+ if req.save_only:
3776
+ await _client(ctx)(
3777
+ fn.SaveWallPaperRequest(wallpaper=reference, unsave=False, settings=settings)
3778
+ )
3779
+ return WallpaperInstalled(
3780
+ slug=req.wallpaper, installed=False, saved=True, settings=_wallpaper_settings(settings)
3781
+ )
3782
+
3783
+ await _client(ctx)(fn.InstallWallPaperRequest(wallpaper=reference, settings=settings))
3784
+ return WallpaperInstalled(
3785
+ slug=req.wallpaper,
3786
+ installed=True,
3787
+ saved=True,
3788
+ settings=_wallpaper_settings(settings),
3789
+ )
3790
+
3791
+
3792
+ SPEC_WALLPAPER_SET = OperationSpec(
3793
+ id="media.wallpaper.set",
3794
+ request=WallpaperSetReq,
3795
+ response=WallpaperInstalled,
3796
+ impl=wallpaper_set,
3797
+ summary="Make a wallpaper the account default",
3798
+ mutating=True,
3799
+ columns=("slug", "installed", "saved"),
3800
+ headers=("Slug", "Installed", "Saved"),
3801
+ example={"slug": "Ycb0FfC6", "installed": True, "saved": True},
3802
+ example_args="media wallpaper set Ycb0FfC6 --blur",
3803
+ covers=("wallpaper.save-install",),
3804
+ )
3805
+
3806
+
3807
+ class WallpaperRemoveReq(Request):
3808
+ wallpaper: Annotated[
3809
+ list[str], arg(0, metavar="WALLPAPER", variadic=True, help="Slugs or links to unsave.")
3810
+ ] = []
3811
+
3812
+
3813
+ async def wallpaper_remove(ctx: OpContext, req: WallpaperRemoveReq) -> WallpaperRemoved:
3814
+ """Remove wallpapers from the saved gallery.
3815
+
3816
+ Not the same as changing the current one: `media wallpaper set --reset`
3817
+ is the "restore defaults" button.
3818
+ """
3819
+ from telethon.tl import types
3820
+ from telethon.tl.functions import account as fn
3821
+
3822
+ slugs = list(req.wallpaper)
3823
+ if not slugs:
3824
+ raise UsageError("name at least one wallpaper", field="wallpaper")
3825
+ for slug in slugs:
3826
+ await _client(ctx)(
3827
+ fn.SaveWallPaperRequest(
3828
+ wallpaper=_wallpaper_ref(slug), unsave=True, settings=types.WallPaperSettings()
3829
+ )
3830
+ )
3831
+ return WallpaperRemoved(slugs=slugs, removed=len(slugs))
3832
+
3833
+
3834
+ SPEC_WALLPAPER_REMOVE = OperationSpec(
3835
+ id="media.wallpaper.remove",
3836
+ request=WallpaperRemoveReq,
3837
+ response=WallpaperRemoved,
3838
+ impl=wallpaper_remove,
3839
+ summary="Remove a wallpaper from the saved list",
3840
+ mutating=True,
3841
+ destructive=True,
3842
+ columns=("slugs", "removed"),
3843
+ headers=("Slugs", "Removed"),
3844
+ example={"slugs": ["Ycb0FfC6"], "removed": 1},
3845
+ example_args="media wallpaper remove Ycb0FfC6",
3846
+ covers_partial=("wallpaper.save-install",),
3847
+ coverage_note="Installing is `media wallpaper set`; this is only the saved gallery.",
3848
+ )
3849
+
3850
+
3851
+ class WallpaperUploadReq(Request):
3852
+ path: Annotated[str, arg(0, metavar="PATH", kind="path", help="Image to upload.")]
3853
+ pattern: Annotated[bool, opt("--pattern", help="Treat the image as a tintable pattern.")] = (
3854
+ False
3855
+ )
3856
+ colors: Annotated[list[str], opt("--colors", metavar="HEX", help="1-4 hex fill colours.")] = []
3857
+ blur: Annotated[bool, opt("--blur", help="Store blurred.")] = False
3858
+ motion: Annotated[bool, opt("--motion", help="Store with parallax motion.")] = False
3859
+ intensity: Annotated[
3860
+ int, opt("--intensity", metavar="N", help="Pattern intensity.", ge=-100, le=100)
3861
+ ] = 50
3862
+ for_chat: Annotated[bool, opt("--for-chat", help="Upload for use as a per-chat wallpaper.")] = (
3863
+ False
3864
+ )
3865
+
3866
+
3867
+ async def wallpaper_upload(ctx: OpContext, req: WallpaperUploadReq) -> WallpaperUploaded:
3868
+ """Upload a custom wallpaper image or pattern.
3869
+
3870
+ The slug that comes back is what `media wallpaper set` and `chat wallpaper
3871
+ set` consume; `--for-chat` is required for the latter, and the server
3872
+ rejects the wrong one rather than silently ignoring it.
3873
+ """
3874
+ import mimetypes
3875
+
3876
+ from telethon.tl import types
3877
+ from telethon.tl.functions import account as fn
3878
+
3879
+ path = Path(os.path.expanduser(req.path))
3880
+ if not path.exists():
3881
+ raise UsageError(f"{req.path} does not exist", field="path")
3882
+ upload_service = getattr(ctx, "upload_file", None)
3883
+ if upload_service is None: # pragma: no cover
3884
+ raise UsageError("this context cannot upload files")
3885
+ handle = await upload_service(path)
3886
+
3887
+ colours = [_colour(value) for value in req.colors][:4]
3888
+ padded = colours + [None] * (4 - len(colours))
3889
+ settings = types.WallPaperSettings(
3890
+ blur=req.blur or None,
3891
+ motion=req.motion or None,
3892
+ background_color=padded[0],
3893
+ second_background_color=padded[1],
3894
+ third_background_color=padded[2],
3895
+ fourth_background_color=padded[3],
3896
+ intensity=req.intensity if req.pattern else None,
3897
+ )
3898
+ result = await _client(ctx)(
3899
+ fn.UploadWallPaperRequest(
3900
+ file=handle,
3901
+ mime_type=mimetypes.guess_type(path.name)[0] or "image/jpeg",
3902
+ settings=settings,
3903
+ for_chat=req.for_chat or None,
3904
+ )
3905
+ )
3906
+ parsed = _wallpaper_settings(settings)
3907
+ slug = getattr(result, "slug", None)
3908
+ return WallpaperUploaded(
3909
+ id=int(getattr(result, "id", 0) or 0),
3910
+ access_hash=getattr(result, "access_hash", None),
3911
+ slug=slug,
3912
+ link=_wallpaper_link(slug, parsed),
3913
+ settings=parsed,
3914
+ )
3915
+
3916
+
3917
+ SPEC_WALLPAPER_UPLOAD = OperationSpec(
3918
+ id="media.wallpaper.upload",
3919
+ request=WallpaperUploadReq,
3920
+ response=WallpaperUploaded,
3921
+ impl=wallpaper_upload,
3922
+ summary="Upload a custom wallpaper image or pattern",
3923
+ mutating=True,
3924
+ rate_class="file",
3925
+ timeout_s=300,
3926
+ columns=("slug", "link"),
3927
+ headers=("Slug", "Link"),
3928
+ example={
3929
+ "id": 5947530738857476,
3930
+ "slug": "Ycb0FfC6",
3931
+ "link": "https://t.me/bg/Ycb0FfC6",
3932
+ },
3933
+ example_args="media wallpaper upload background.jpg",
3934
+ covers_partial=("wallpaper.save-install",),
3935
+ coverage_note="Installing the uploaded slug is `media wallpaper set`.",
3936
+ )
3937
+
3938
+
3939
+ # ---------------------------------------------------------------------------
3940
+ # media auto-download / auto-save / sensitive
3941
+ # ---------------------------------------------------------------------------
3942
+
3943
+
3944
+ class AutoDownloadGetReq(Request):
3945
+ preset: Annotated[
3946
+ str | None, choice("low", "medium", "high", help="Show one preset instead of all three.")
3947
+ ] = None
3948
+
3949
+
3950
+ def _preset_model(name: str, settings: Any) -> AutoDownloadPreset:
3951
+ return AutoDownloadPreset(
3952
+ preset=name,
3953
+ disabled=bool(getattr(settings, "disabled", False)),
3954
+ photo_size_max=int(getattr(settings, "photo_size_max", 0) or 0),
3955
+ video_size_max=int(getattr(settings, "video_size_max", 0) or 0),
3956
+ file_size_max=int(getattr(settings, "file_size_max", 0) or 0),
3957
+ video_upload_maxbitrate=int(getattr(settings, "video_upload_maxbitrate", 0) or 0),
3958
+ video_preload_large=bool(getattr(settings, "video_preload_large", False)),
3959
+ audio_preload_next=bool(getattr(settings, "audio_preload_next", False)),
3960
+ phonecalls_less_data=bool(getattr(settings, "phonecalls_less_data", False)),
3961
+ stories_preload=bool(getattr(settings, "stories_preload", False)),
3962
+ )
3963
+
3964
+
3965
+ async def auto_download_get(ctx: OpContext, req: AutoDownloadGetReq) -> AutoDownloadSettings:
3966
+ """The cloud auto-download presets.
3967
+
3968
+ A synced *preference*, not behaviour: the downloading itself is the
3969
+ client's, and in tlgr that is `media watch --download
3970
+ --use-cloud-settings`. The GUI's mobile/wifi/roaming rows are these three.
3971
+ """
3972
+ from telethon.tl.functions import account as fn
3973
+
3974
+ result = await _client(ctx)(fn.GetAutoDownloadSettingsRequest())
3975
+ presets = [
3976
+ _preset_model(name, getattr(result, name, None))
3977
+ for name in ("low", "medium", "high")
3978
+ if getattr(result, name, None) is not None
3979
+ ]
3980
+ if req.preset:
3981
+ presets = [preset for preset in presets if preset.preset == req.preset]
3982
+ return AutoDownloadSettings(presets=presets)
3983
+
3984
+
3985
+ SPEC_AUTO_DOWNLOAD_GET = OperationSpec(
3986
+ id="media.auto-download.get",
3987
+ request=AutoDownloadGetReq,
3988
+ response=AutoDownloadSettings,
3989
+ impl=auto_download_get,
3990
+ summary="Read the cloud auto-download presets",
3991
+ example={
3992
+ "presets": [
3993
+ {"preset": "low", "photo_size_max": 1048576, "video_size_max": 512000},
3994
+ {"preset": "medium", "photo_size_max": 1048576, "video_size_max": 10485760},
3995
+ ]
3996
+ },
3997
+ example_args="media auto-download get",
3998
+ covers_partial=("media.auto-download-settings",),
3999
+ coverage_note="Writing is `media auto-download set`.",
4000
+ )
4001
+
4002
+
4003
+ class AutoDownloadSetReq(Request):
4004
+ preset: Annotated[str, choice("low", "medium", "high", help="Which preset to write.")] = (
4005
+ "medium"
4006
+ )
4007
+ disabled: Annotated[
4008
+ bool | None, opt("--disabled/--enabled", help="Turn auto-download off for this preset.")
4009
+ ] = None
4010
+ photo_max: Annotated[str | None, opt("--photo-max", metavar="SIZE", help="Photo cap.")] = None
4011
+ video_max: Annotated[str | None, opt("--video-max", metavar="SIZE", help="Video cap.")] = None
4012
+ file_max: Annotated[str | None, opt("--file-max", metavar="SIZE", help="File cap.")] = None
4013
+ preload_large_video: Annotated[
4014
+ bool | None,
4015
+ opt("--preload-large-video/--no-preload-large-video", help="Preload big videos."),
4016
+ ] = None
4017
+ preload_next_audio: Annotated[
4018
+ bool | None,
4019
+ opt("--preload-next-audio/--no-preload-next-audio", help="Preload the next track."),
4020
+ ] = None
4021
+ preload_stories: Annotated[
4022
+ bool | None, opt("--preload-stories/--no-preload-stories", help="Preload stories.")
4023
+ ] = None
4024
+ less_call_data: Annotated[
4025
+ bool | None, opt("--less-call-data/--no-less-call-data", help="Use less data for calls.")
4026
+ ] = None
4027
+ reset: Annotated[bool, opt("--reset", help="Restore the server defaults.")] = False
4028
+
4029
+
4030
+ async def auto_download_set(ctx: OpContext, req: AutoDownloadSetReq) -> AutoDownloadSaved:
4031
+ """Write one auto-download preset.
4032
+
4033
+ Read-modify-write, because the request carries a whole
4034
+ `autoDownloadSettings`: writing one field from scratch would blank the
4035
+ rest of that preset.
4036
+ """
4037
+ from telethon.tl import types
4038
+ from telethon.tl.functions import account as fn
4039
+
4040
+ current = await auto_download_get(ctx, AutoDownloadGetReq(preset=req.preset))
4041
+ base = current.presets[0] if current.presets else AutoDownloadPreset(preset=req.preset)
4042
+ if req.reset:
4043
+ base = AutoDownloadPreset(preset=req.preset)
4044
+
4045
+ settings = types.AutoDownloadSettings(
4046
+ photo_size_max=_size_arg(req.photo_max, "photo_max") or base.photo_size_max,
4047
+ video_size_max=_size_arg(req.video_max, "video_max") or base.video_size_max,
4048
+ file_size_max=_size_arg(req.file_max, "file_max") or base.file_size_max,
4049
+ video_upload_maxbitrate=base.video_upload_maxbitrate,
4050
+ small_queue_active_operations_max=5,
4051
+ large_queue_active_operations_max=2,
4052
+ disabled=(base.disabled if req.disabled is None else req.disabled) or None,
4053
+ video_preload_large=(
4054
+ base.video_preload_large if req.preload_large_video is None else req.preload_large_video
4055
+ )
4056
+ or None,
4057
+ audio_preload_next=(
4058
+ base.audio_preload_next if req.preload_next_audio is None else req.preload_next_audio
4059
+ )
4060
+ or None,
4061
+ phonecalls_less_data=(
4062
+ base.phonecalls_less_data if req.less_call_data is None else req.less_call_data
4063
+ )
4064
+ or None,
4065
+ stories_preload=(
4066
+ base.stories_preload if req.preload_stories is None else req.preload_stories
4067
+ )
4068
+ or None,
4069
+ )
4070
+ await _client(ctx)(
4071
+ fn.SaveAutoDownloadSettingsRequest(
4072
+ settings=settings,
4073
+ low=req.preset == "low" or None,
4074
+ high=req.preset == "high" or None,
4075
+ )
4076
+ )
4077
+ return AutoDownloadSaved(
4078
+ preset=req.preset, ok=True, settings=_preset_model(req.preset, settings)
4079
+ )
4080
+
4081
+
4082
+ SPEC_AUTO_DOWNLOAD_SET = OperationSpec(
4083
+ id="media.auto-download.set",
4084
+ request=AutoDownloadSetReq,
4085
+ response=AutoDownloadSaved,
4086
+ impl=auto_download_set,
4087
+ summary="Write the cloud auto-download presets",
4088
+ mutating=True,
4089
+ columns=("preset", "ok"),
4090
+ headers=("Preset", "OK"),
4091
+ example={"preset": "medium", "ok": True},
4092
+ example_args="media auto-download set --preset medium --video-max 10M",
4093
+ covers=("media.auto-download-settings",),
4094
+ )
4095
+
4096
+
4097
+ class AutoSaveGetReq(Request):
4098
+ chat: Annotated[
4099
+ PeerRef | None,
4100
+ opt("--chat", metavar="CHAT", kind="peer", help="Show the exception for one chat."),
4101
+ ] = None
4102
+
4103
+
4104
+ def _auto_save_rule(settings: Any) -> AutoSaveRule | None:
4105
+ if settings is None:
4106
+ return None
4107
+ return AutoSaveRule(
4108
+ photos=bool(getattr(settings, "photos", False)),
4109
+ videos=bool(getattr(settings, "videos", False)),
4110
+ video_max_size=getattr(settings, "video_max_size", None),
4111
+ )
4112
+
4113
+
4114
+ async def auto_save_get(ctx: OpContext, req: AutoSaveGetReq) -> AutoSaveSettings:
4115
+ """The cloud save-to-gallery rules.
4116
+
4117
+ A synced preference tlgr honours in `media watch --download
4118
+ --use-cloud-settings`; it keeps no gallery of its own.
4119
+ """
4120
+ from telethon.tl.functions import account as fn
4121
+
4122
+ result = await _client(ctx)(fn.GetAutoSaveSettingsRequest())
4123
+ peers = {
4124
+ entity.id: entity_to_peer(entity)
4125
+ for entity in [
4126
+ *(getattr(result, "users", None) or []),
4127
+ *(getattr(result, "chats", None) or []),
4128
+ ]
4129
+ }
4130
+ exceptions: list[AutoSaveException] = []
4131
+ from tlgr.ops._serialize import peer_id_of as marked_of
4132
+
4133
+ for entry in getattr(result, "exceptions", None) or []:
4134
+ chat_id = marked_of(getattr(entry, "peer", None)) or 0
4135
+ exceptions.append(
4136
+ AutoSaveException(
4137
+ chat_id=chat_id,
4138
+ chat=peers.get(abs(chat_id) % 1000000000000),
4139
+ rule=_auto_save_rule(getattr(entry, "settings", None)),
4140
+ )
4141
+ )
4142
+ if req.chat is not None:
4143
+ wanted = _send.peer_id_of(await _send.resolve(ctx, req.chat))
4144
+ exceptions = [entry for entry in exceptions if entry.chat_id == wanted]
4145
+ return AutoSaveSettings(
4146
+ users=_auto_save_rule(getattr(result, "users_settings", None)),
4147
+ chats=_auto_save_rule(getattr(result, "chats_settings", None)),
4148
+ broadcasts=_auto_save_rule(getattr(result, "broadcasts_settings", None)),
4149
+ exceptions=exceptions,
4150
+ )
4151
+
4152
+
4153
+ SPEC_AUTO_SAVE_GET = OperationSpec(
4154
+ id="media.auto-save.get",
4155
+ request=AutoSaveGetReq,
4156
+ response=AutoSaveSettings,
4157
+ impl=auto_save_get,
4158
+ summary="Read the cloud save-to-gallery rules",
4159
+ example={
4160
+ "users": {"photos": True, "videos": False},
4161
+ "chats": {"photos": False, "videos": False},
4162
+ "exceptions": [],
4163
+ },
4164
+ example_args="media auto-save get",
4165
+ covers_partial=("media.auto-save-to-gallery-settings",),
4166
+ coverage_note="Writing is `media auto-save set`.",
4167
+ )
4168
+
4169
+
4170
+ class AutoSaveSetReq(Request):
4171
+ scope: Annotated[
4172
+ str,
4173
+ choice("users", "groups", "channels", help="Which category to write."),
4174
+ ] = "users"
4175
+ chat: Annotated[
4176
+ PeerRef | None,
4177
+ opt("--chat", metavar="CHAT", kind="peer", help="Write an exception for one chat."),
4178
+ ] = None
4179
+ photos: Annotated[bool | None, opt("--photos/--no-photos", help="Auto-save photos.")] = None
4180
+ videos: Annotated[bool | None, opt("--videos/--no-videos", help="Auto-save videos.")] = None
4181
+ video_max: Annotated[str | None, opt("--video-max", metavar="SIZE", help="Video size cap.")] = (
4182
+ None
4183
+ )
4184
+ clear_exceptions: Annotated[
4185
+ bool, opt("--clear-exceptions", help="Delete every per-chat exception.")
4186
+ ] = False
4187
+
4188
+
4189
+ async def auto_save_set(ctx: OpContext, req: AutoSaveSetReq) -> AutoSaveSaved:
4190
+ """Write one save-to-gallery scope.
4191
+
4192
+ The API takes exactly one scope per call — users, groups, channels or a
4193
+ single peer — so this is one `--scope` choice rather than three flags that
4194
+ could contradict each other.
4195
+ """
4196
+ from telethon.tl import types
4197
+ from telethon.tl.functions import account as fn
4198
+
4199
+ if req.clear_exceptions:
4200
+ await _client(ctx)(fn.DeleteAutoSaveExceptionsRequest())
4201
+ return AutoSaveSaved(scope="exceptions", ok=True, cleared_exceptions=True)
4202
+
4203
+ settings = types.AutoSaveSettings(
4204
+ photos=req.photos,
4205
+ videos=req.videos,
4206
+ video_max_size=_size_arg(req.video_max, "video_max"),
4207
+ )
4208
+ peer = await _send.resolve(ctx, req.chat) if req.chat is not None else None
4209
+ await _client(ctx)(
4210
+ fn.SaveAutoSaveSettingsRequest(
4211
+ settings=settings,
4212
+ users=(req.scope == "users" and peer is None) or None,
4213
+ chats=(req.scope == "groups" and peer is None) or None,
4214
+ broadcasts=(req.scope == "channels" and peer is None) or None,
4215
+ peer=peer,
4216
+ )
4217
+ )
4218
+ return AutoSaveSaved(
4219
+ scope="chat" if peer is not None else req.scope,
4220
+ ok=True,
4221
+ settings=_auto_save_rule(settings),
4222
+ )
4223
+
4224
+
4225
+ SPEC_AUTO_SAVE_SET = OperationSpec(
4226
+ id="media.auto-save.set",
4227
+ request=AutoSaveSetReq,
4228
+ response=AutoSaveSaved,
4229
+ impl=auto_save_set,
4230
+ summary="Write the cloud save-to-gallery rules",
4231
+ mutating=True,
4232
+ columns=("scope", "ok"),
4233
+ headers=("Scope", "OK"),
4234
+ example={"scope": "users", "ok": True, "settings": {"photos": True, "videos": True}},
4235
+ example_args="media auto-save set --scope users --photos",
4236
+ covers=("media.auto-save-to-gallery-settings",),
4237
+ )
4238
+
4239
+
4240
+ class SensitiveGetReq(Request):
4241
+ pass
4242
+
4243
+
4244
+ async def sensitive_get(ctx: OpContext, req: SensitiveGetReq) -> ContentSettings:
4245
+ """Whether 18+ media is shown, and whether that can be changed from here.
4246
+
4247
+ `sensitive_can_change = false` is the interesting case: in some regions
4248
+ the toggle only unlocks after an age check inside a Telegram-designated
4249
+ mini app, which a terminal cannot render. tlgr reports that and names the
4250
+ bot rather than retrying a write the server will refuse.
4251
+ """
4252
+ from telethon.tl.functions import account as fn
4253
+
4254
+ result = await _client(ctx)(fn.GetContentSettingsRequest())
4255
+ can_change = bool(getattr(result, "sensitive_can_change", False))
4256
+ settings = ContentSettings(
4257
+ sensitive_enabled=bool(getattr(result, "sensitive_enabled", False)),
4258
+ sensitive_can_change=can_change,
4259
+ )
4260
+ if not can_change:
4261
+ values = await _media.app_config(ctx)
4262
+ bot = values.get("verify_age_bot_username")
4263
+ settings.age_verification_required = bool(values.get("verify_age_min") or bot)
4264
+ settings.age_verification_bot = f"@{bot}" if bot else None
4265
+ settings.reason = (
4266
+ "this account cannot change the setting from an API client; the toggle "
4267
+ "unlocks after an age check inside Telegram's own verification mini app"
4268
+ )
4269
+ return settings
4270
+
4271
+
4272
+ SPEC_SENSITIVE_GET = OperationSpec(
4273
+ id="media.sensitive.get",
4274
+ request=SensitiveGetReq,
4275
+ response=ContentSettings,
4276
+ impl=sensitive_get,
4277
+ summary="Whether 18+ media is shown, and whether that can be changed here",
4278
+ columns=("sensitive_enabled", "sensitive_can_change"),
4279
+ headers=("Enabled", "Can change"),
4280
+ example={"sensitive_enabled": False, "sensitive_can_change": True},
4281
+ example_args="media sensitive get",
4282
+ covers=("media.age-verification",),
4283
+ covers_partial=("media.sensitive-content-setting",),
4284
+ coverage_note="Writing the toggle is `media sensitive set`.",
4285
+ )
4286
+
4287
+
4288
+ class SensitiveSetReq(Request):
4289
+ state: Annotated[str, arg(0, metavar="STATE", help="on or off.")]
4290
+
4291
+
4292
+ async def sensitive_set(ctx: OpContext, req: SensitiveSetReq) -> ContentSettingsSaved:
4293
+ """Show or hide 18+ media, after checking that this account may.
4294
+
4295
+ The pre-flight is the point: letting the server reject the write produces
4296
+ a confusing failure, while `sensitive_can_change` answers "you cannot do
4297
+ this from here, and here is why" before anything is sent.
4298
+ """
4299
+ from telethon.tl.functions import account as fn
4300
+
4301
+ wanted = str(req.state).strip().lower()
4302
+ if wanted not in ("on", "off", "true", "false", "1", "0"):
4303
+ raise UsageError("STATE is `on` or `off`", field="state")
4304
+ enable = wanted in ("on", "true", "1")
4305
+
4306
+ current = await sensitive_get(ctx, SensitiveGetReq())
4307
+ if current.sensitive_enabled == enable:
4308
+ ctx.mark_already() if hasattr(ctx, "mark_already") else None
4309
+ return ContentSettingsSaved(sensitive_enabled=enable, ok=True, already=True)
4310
+ if not current.sensitive_can_change:
4311
+ raise PermissionError_(
4312
+ current.reason
4313
+ or "this account may not change the sensitive-content setting from an API client"
4314
+ )
4315
+ await _client(ctx)(fn.SetContentSettingsRequest(sensitive_enabled=enable or None))
4316
+ return ContentSettingsSaved(sensitive_enabled=enable, ok=True)
4317
+
4318
+
4319
+ SPEC_SENSITIVE_SET = OperationSpec(
4320
+ id="media.sensitive.set",
4321
+ request=SensitiveSetReq,
4322
+ response=ContentSettingsSaved,
4323
+ impl=sensitive_set,
4324
+ summary="Show or hide 18+ media",
4325
+ mutating=True,
4326
+ idempotent=True,
4327
+ columns=("sensitive_enabled", "ok", "already"),
4328
+ headers=("Enabled", "OK", "Already"),
4329
+ example={"sensitive_enabled": True, "ok": True},
4330
+ example_args="media sensitive set on",
4331
+ covers=("media.sensitive-content-setting",),
4332
+ covers_partial=("media.age-verification",),
4333
+ coverage_note="The verification flow itself runs in Telegram's mini app, not here.",
4334
+ )
4335
+
4336
+
4337
+ # ---------------------------------------------------------------------------
4338
+ # media storage (local)
4339
+ # ---------------------------------------------------------------------------
4340
+
4341
+
4342
+ _KIND_SUFFIXES = {
4343
+ "photo": (".jpg", ".jpeg", ".png", ".webp", ".heic"),
4344
+ "video": (".mp4", ".mkv", ".mov", ".webm"),
4345
+ "music": (".mp3", ".m4a", ".flac"),
4346
+ "voice": (".ogg", ".oga", ".opus"),
4347
+ "gif": (".gif",),
4348
+ "thumb": (".thumb.jpg",),
4349
+ "partial": (".part",),
4350
+ }
4351
+
4352
+
4353
+ def _kind_of_file(path: Path) -> str:
4354
+ suffix = path.suffix.lower()
4355
+ for kind, suffixes in _KIND_SUFFIXES.items():
4356
+ if suffix in suffixes:
4357
+ return kind
4358
+ return "file"
4359
+
4360
+
4361
+ def _storage_root(ctx: OpContext) -> Path:
4362
+ from tlgr.core.config import get_downloads_dir
4363
+
4364
+ return Path(get_downloads_dir())
4365
+
4366
+
4367
+ class StorageGetReq(Request):
4368
+ by_chat: Annotated[bool, opt("--by-chat", help="Break the total down per chat.")] = False
4369
+ by_type: Annotated[bool, opt("--by-type", help="Break the total down per media type.")] = False
4370
+
4371
+
4372
+ async def storage_get(ctx: OpContext, req: StorageGetReq) -> StorageUsage:
4373
+ """Disk used by tlgr's own downloads.
4374
+
4375
+ Purely local: Telegram stores no cache statistics, and the scope is the
4376
+ configured downloads root and nothing else — never the user's own
4377
+ directories.
4378
+ """
4379
+ root = _storage_root(ctx)
4380
+ usage = StorageUsage(root=str(root))
4381
+ if not root.exists():
4382
+ return usage
4383
+ oldest: float | None = None
4384
+ for path in root.rglob("*"):
4385
+ if not path.is_file():
4386
+ continue
4387
+ size = path.stat().st_size
4388
+ usage.files += 1
4389
+ usage.bytes += size
4390
+ if path.suffix == ".part":
4391
+ usage.partials += 1
4392
+ stamp = path.stat().st_mtime
4393
+ oldest = stamp if oldest is None else min(oldest, stamp)
4394
+ if req.by_chat:
4395
+ chat = path.relative_to(root).parts[0]
4396
+ usage.by_chat[chat] = usage.by_chat.get(chat, 0) + size
4397
+ if req.by_type:
4398
+ kind = _kind_of_file(path)
4399
+ usage.by_type[kind] = usage.by_type.get(kind, 0) + size
4400
+ if oldest is not None:
4401
+ from datetime import datetime, timezone
4402
+
4403
+ usage.oldest = datetime.fromtimestamp(oldest, timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
4404
+ return usage
4405
+
4406
+
4407
+ SPEC_STORAGE_GET = OperationSpec(
4408
+ id="media.storage.get",
4409
+ request=StorageGetReq,
4410
+ response=StorageUsage,
4411
+ impl=storage_get,
4412
+ summary="Disk usage of tlgr's downloads and cache",
4413
+ surface=Surface.LOCAL,
4414
+ needs_account=False,
4415
+ needs_auth=False,
4416
+ rate_class="local",
4417
+ columns=("root", "files", "bytes", "partials"),
4418
+ headers=("Root", "Files", "Bytes", "Partial"),
4419
+ example={
4420
+ "root": "/home/u/.tlgr/downloads",
4421
+ "bytes": 918273645,
4422
+ "files": 412,
4423
+ "partials": 1,
4424
+ },
4425
+ example_args="media storage get --by-type",
4426
+ covers=("media.storage-cache",),
4427
+ )
4428
+
4429
+
4430
+ class StorageClearReq(Request):
4431
+ older_than: Annotated[
4432
+ int | None,
4433
+ opt(
4434
+ "--older-than", metavar="DURATION", kind="duration", help="Only files older than this."
4435
+ ),
4436
+ ] = None
4437
+ type: Annotated[list[str], opt("--type", metavar="KIND", help="Only these kinds.")] = []
4438
+ chat: Annotated[
4439
+ str | None, opt("--chat", metavar="CHAT", help="Only this chat's directory.")
4440
+ ] = None
4441
+ keep_days: Annotated[
4442
+ int | None, opt("--keep-days", metavar="N", help="Persist a keep-media TTL in days.")
4443
+ ] = None
4444
+
4445
+
4446
+ async def storage_clear(ctx: OpContext, req: StorageClearReq) -> StorageCleared:
4447
+ """Delete cached downloads, and only inside tlgr's own root.
4448
+
4449
+ Anything outside the configured downloads directory is out of scope by
4450
+ construction: the walk starts there and never follows a link out of it.
4451
+ """
4452
+ root = _storage_root(ctx)
4453
+ cleared = StorageCleared(keep_days=req.keep_days)
4454
+ if not root.exists():
4455
+ return cleared
4456
+ cutoff = time.time() - req.older_than if req.older_than else None
4457
+ wanted = set(req.type)
4458
+ scope = root / req.chat if req.chat else root
4459
+
4460
+ for path in scope.rglob("*"):
4461
+ if not path.is_file() or path.is_symlink():
4462
+ continue
4463
+ if cutoff is not None and path.stat().st_atime > cutoff:
4464
+ cleared.kept_files += 1
4465
+ continue
4466
+ if wanted and _kind_of_file(path) not in wanted:
4467
+ cleared.kept_files += 1
4468
+ continue
4469
+ size = path.stat().st_size
4470
+ with contextlib.suppress(OSError):
4471
+ path.unlink()
4472
+ cleared.deleted_files += 1
4473
+ cleared.freed_bytes += size
4474
+ return cleared
4475
+
4476
+
4477
+ SPEC_STORAGE_CLEAR = OperationSpec(
4478
+ id="media.storage.clear",
4479
+ request=StorageClearReq,
4480
+ response=StorageCleared,
4481
+ impl=storage_clear,
4482
+ summary="Delete cached downloads",
4483
+ surface=Surface.LOCAL,
4484
+ needs_account=False,
4485
+ needs_auth=False,
4486
+ mutating=True,
4487
+ destructive=True,
4488
+ rate_class="local",
4489
+ columns=("deleted_files", "freed_bytes", "kept_files"),
4490
+ headers=("Deleted", "Freed", "Kept"),
4491
+ example={"deleted_files": 128, "freed_bytes": 419430400, "kept_files": 284},
4492
+ example_args="media storage clear --older-than 30d",
4493
+ covers_partial=("media.storage-cache",),
4494
+ coverage_note="Reporting the usage is `media storage get`.",
4495
+ )