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/export.py ADDED
@@ -0,0 +1,596 @@
1
+ """The `export` group: Telegram's data export (takeout).
2
+
3
+ A takeout is a *mode*, not a request. `account.initTakeoutSession` returns an
4
+ id, and every subsequent call — `upload.getFile` included — has to be wrapped
5
+ in `invokeWithTakeout` or it simply is not part of the export. `file_max_size`
6
+ is fixed at that moment and cannot be raised later.
7
+
8
+ Two consequences shape this group. The session id is held by the daemon, in
9
+ memory, for the account it was opened on, so `export start` and `export
10
+ message download` are separate commands rather than one long-running call an
11
+ interrupted terminal would abandon. And `TAKEOUT_INIT_DELAY_X` — another
12
+ logged-in session has to approve the export, or 24 hours must pass if there is
13
+ none — is reported as a structured error with `retry_after` rather than slept
14
+ through in silence.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import contextlib
20
+ import json
21
+ from collections.abc import AsyncIterator
22
+ from pathlib import Path
23
+ from typing import Annotated, Any
24
+
25
+ from tlgr.core.errors import EXIT_EMPTY, NotFoundError, RateLimitError, UsageError
26
+ from tlgr.core.pagination import PageKind, build_page
27
+ from tlgr.models.base import Request
28
+ from tlgr.models.export import (
29
+ ExportedFile,
30
+ ExportResult,
31
+ MessageRange,
32
+ TakeoutSession,
33
+ TakeoutStatus,
34
+ )
35
+ from tlgr.models.message import Message
36
+ from tlgr.models.page import Page
37
+ from tlgr.models.peer import PeerRef
38
+ from tlgr.ops import _send
39
+ from tlgr.ops._params import opt, parse_dt
40
+ from tlgr.ops._serialize import message_to_model
41
+ from tlgr.ops._spec import OpContext, OperationSpec, Surface
42
+
43
+ __all__ = [name for name in dir() if name.startswith("SPEC_")]
44
+
45
+ _SCOPES = ("contacts", "messages", "users", "chats", "megagroups", "channels", "bots", "files")
46
+
47
+
48
+ def _client(ctx: OpContext) -> Any:
49
+ client = getattr(ctx, "client", None)
50
+ if client is None:
51
+ raise UsageError("this operation needs a connected account")
52
+ return client
53
+
54
+
55
+ def _sessions(ctx: OpContext) -> Any:
56
+ daemon = getattr(ctx, "daemon", None)
57
+ return getattr(daemon, "sessions", None)
58
+
59
+
60
+ def _store(ctx: OpContext) -> dict[str, dict[str, Any]]:
61
+ """Open takeout sessions, per account, on the daemon.
62
+
63
+ In memory rather than on disk, deliberately: a takeout id is only valid
64
+ for the connection that opened it, so persisting one across a restart
65
+ would hand back an id every subsequent request would be rejected with.
66
+ """
67
+ daemon = getattr(ctx, "daemon", None)
68
+ if daemon is None:
69
+ raise UsageError("this operation runs inside the daemon")
70
+ existing = getattr(daemon, "takeouts", None)
71
+ if existing is None:
72
+ existing = {}
73
+ daemon.takeouts = existing
74
+ return existing
75
+
76
+
77
+ def _active(ctx: OpContext) -> dict[str, Any]:
78
+ entry = _store(ctx).get(ctx.account)
79
+ if not entry:
80
+ raise NotFoundError("no takeout session is open for this account. Run: tlgr export start")
81
+ return entry
82
+
83
+
84
+ async def _invoke(ctx: OpContext, request: Any) -> Any:
85
+ """Run *request* inside the account's takeout session when one is open.
86
+
87
+ Outside a session the call still works — it simply runs against the normal
88
+ flood budget instead of the takeout one — which is why `export account
89
+ download` is useful before `export start` has been approved.
90
+ """
91
+ from telethon.tl import functions
92
+
93
+ client = _client(ctx)
94
+ entry = _store(ctx).get(ctx.account)
95
+ if not entry:
96
+ return await client(request)
97
+ return await client(
98
+ functions.InvokeWithTakeoutRequest(takeout_id=entry["takeout_id"], query=request)
99
+ )
100
+
101
+
102
+ # ---------------------------------------------------------------------------
103
+ # export start / status / end
104
+ # ---------------------------------------------------------------------------
105
+
106
+
107
+ class ExportStartReq(Request):
108
+ contacts: Annotated[bool, opt("--contacts", help="Include contacts.")] = False
109
+ messages: Annotated[bool, opt("--messages", help="Include private-chat history.")] = False
110
+ users: Annotated[bool, opt("--users", help="Include private chats.")] = False
111
+ chats: Annotated[bool, opt("--chats", help="Include basic groups.")] = False
112
+ megagroups: Annotated[bool, opt("--megagroups", help="Include supergroups.")] = False
113
+ channels: Annotated[bool, opt("--channels", help="Include channels.")] = False
114
+ bots: Annotated[bool, opt("--bots", help="Include bot chats.")] = False
115
+ files: Annotated[bool, opt("--files", help="Include media files.")] = False
116
+ max_file_size: Annotated[
117
+ int,
118
+ opt(
119
+ "--max-file-size",
120
+ metavar="BYTES",
121
+ help="file_max_size, declared up front and unchangeable afterwards.",
122
+ ),
123
+ ] = 100 * 1024 * 1024
124
+ wait: Annotated[
125
+ bool, opt("--wait", help="Report TAKEOUT_INIT_DELAY as a wait instead of failing.")
126
+ ] = False
127
+
128
+
129
+ async def export_start(ctx: OpContext, req: ExportStartReq) -> TakeoutSession:
130
+ """Open a takeout session.
131
+
132
+ `file_max_size` cannot be changed later, so it is declared here. A
133
+ `TAKEOUT_INIT_DELAY_X` means another logged-in session has to approve the
134
+ export first — 24 hours if there is none — and it comes back as
135
+ RATE_LIMITED carrying `retry_after`, because "try again later" without
136
+ "how much later" is not actionable.
137
+ """
138
+ from telethon.tl import functions
139
+
140
+ store = _store(ctx)
141
+ if ctx.account in store:
142
+ entry = store[ctx.account]
143
+ ctx.mark_already()
144
+ return TakeoutSession(
145
+ takeout_id=entry["takeout_id"],
146
+ scope=entry["scope"],
147
+ started_at=entry["started_at"],
148
+ max_file_size=entry["max_file_size"],
149
+ already=True,
150
+ )
151
+
152
+ scope = [name for name in _SCOPES if getattr(req, name, False)]
153
+ if not scope:
154
+ raise UsageError(
155
+ "name at least one scope: --messages, --contacts, --channels, --files, …",
156
+ field="messages",
157
+ )
158
+
159
+ request = functions.account.InitTakeoutSessionRequest(
160
+ contacts=req.contacts,
161
+ message_users=req.users or req.messages,
162
+ message_chats=req.chats,
163
+ message_megagroups=req.megagroups,
164
+ message_channels=req.channels,
165
+ files=req.files,
166
+ file_max_size=req.max_file_size if req.files else None,
167
+ )
168
+ try:
169
+ result = await _client(ctx)(request)
170
+ except Exception as exc:
171
+ raise _takeout_delay(exc, waiting=req.wait) from exc
172
+
173
+ entry = {
174
+ "takeout_id": int(getattr(result, "id", 0) or 0),
175
+ "scope": scope,
176
+ "started_at": _now(),
177
+ "max_file_size": req.max_file_size,
178
+ }
179
+ _store(ctx)[ctx.account] = entry
180
+ return TakeoutSession(
181
+ takeout_id=entry["takeout_id"],
182
+ scope=scope,
183
+ started_at=entry["started_at"],
184
+ max_file_size=entry["max_file_size"],
185
+ )
186
+
187
+
188
+ def _takeout_delay(exc: Exception, *, waiting: bool) -> Exception:
189
+ seconds = getattr(exc, "seconds", None)
190
+ if type(exc).__name__ != "TakeoutInitDelayError" and seconds is None:
191
+ return exc
192
+ hint = (
193
+ "another logged-in session has to approve this export (or 24 hours must "
194
+ "pass if there is none). Approve it in Settings → Privacy → Data export."
195
+ )
196
+ if waiting:
197
+ hint += f" Retry in {seconds}s."
198
+ return RateLimitError(f"the export cannot start yet: {hint}", wait_seconds=int(seconds or 0))
199
+
200
+
201
+ def _now() -> str:
202
+ from datetime import datetime, timezone
203
+
204
+ return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
205
+
206
+
207
+ SPEC_EXPORT_START = OperationSpec(
208
+ id="export.start",
209
+ request=ExportStartReq,
210
+ response=TakeoutSession,
211
+ impl=export_start,
212
+ summary="Open a Telegram data-export (takeout) session",
213
+ description=(
214
+ "The returned id wraps every subsequent request in `invokeWithTakeout`, "
215
+ "`upload.getFile` included. `file_max_size` cannot be changed later."
216
+ ),
217
+ aliases=("daemon.takeout.start",),
218
+ mutating=True,
219
+ idempotent=True,
220
+ surface=Surface.DAEMON,
221
+ rate_class="read",
222
+ timeout_s=120,
223
+ columns=("takeout_id", "scope", "started_at", "max_file_size"),
224
+ example={"takeout_id": 1234567890, "scope": ["messages", "files"], "max_file_size": 104857600},
225
+ example_args="export start --messages --files",
226
+ covers_partial=(
227
+ "takeout.contacts",
228
+ "takeout.files",
229
+ "takeout.messages",
230
+ "takeout.personal-info",
231
+ "updates.takeout-session",
232
+ ),
233
+ coverage_note=(
234
+ "opens the session the other export commands run inside; each of them "
235
+ "owns the data it fetches."
236
+ ),
237
+ tags=frozenset({"agent-safe"}),
238
+ )
239
+
240
+
241
+ class ExportStatusReq(Request):
242
+ ranges: Annotated[
243
+ bool, opt("--ranges/--no-ranges", help="Include messages.getSplitRanges output.")
244
+ ] = True
245
+
246
+
247
+ async def export_status(ctx: OpContext, req: ExportStatusReq) -> TakeoutStatus:
248
+ """The active takeout session and the message ranges it must be walked in.
249
+
250
+ Split ranges are not advice. For private chats and basic groups the export
251
+ has to call `messages.getSplitRanges` and then wrap each range in
252
+ `invokeWithMessagesRange`, restarting pagination per range — Telethon does
253
+ none of that, so tlgr wraps it by hand.
254
+ """
255
+ entry = _store(ctx).get(ctx.account)
256
+ if not entry:
257
+ return TakeoutStatus(active=False)
258
+
259
+ status = TakeoutStatus(
260
+ active=True,
261
+ takeout_id=entry["takeout_id"],
262
+ started_at=entry["started_at"],
263
+ scope=entry["scope"],
264
+ max_file_size=entry["max_file_size"],
265
+ )
266
+ if req.ranges:
267
+ from telethon.tl import functions
268
+
269
+ with contextlib.suppress(Exception):
270
+ reply = await _invoke(ctx, functions.messages.GetSplitRangesRequest())
271
+ status.ranges = [
272
+ MessageRange(
273
+ min_id=int(getattr(row, "min_id", 0) or 0),
274
+ max_id=int(getattr(row, "max_id", 0) or 0),
275
+ )
276
+ for row in (reply or [])
277
+ ]
278
+ return status
279
+
280
+
281
+ SPEC_EXPORT_STATUS = OperationSpec(
282
+ id="export.status",
283
+ request=ExportStatusReq,
284
+ response=TakeoutStatus,
285
+ impl=export_status,
286
+ summary="Show the active takeout session and its message ranges",
287
+ aliases=("daemon.takeout.status",),
288
+ needs_client=False,
289
+ surface=Surface.DAEMON,
290
+ idempotent=True,
291
+ rate_class="read",
292
+ timeout_s=60,
293
+ columns=("active", "takeout_id", "scope", "max_file_size"),
294
+ example={"active": True, "takeout_id": 1234567890, "scope": ["messages"]},
295
+ example_args="export status",
296
+ covers=("updates.takeout-session", "updates.takeout-split-ranges"),
297
+ tags=frozenset({"agent-safe"}),
298
+ )
299
+
300
+
301
+ class ExportEndReq(Request):
302
+ failed: Annotated[
303
+ bool, opt("--failed", help="Report the export as unsuccessful (success=false).")
304
+ ] = False
305
+
306
+
307
+ async def export_end(ctx: OpContext, req: ExportEndReq) -> ExportResult:
308
+ """Close the takeout session.
309
+
310
+ Must be called: an open session blocks the next export, and the next
311
+ `export start` then fails with a delay that looks like Telegram refusing
312
+ rather than like a session nobody closed.
313
+ """
314
+ from telethon.tl import functions
315
+
316
+ store = _store(ctx)
317
+ entry = store.get(ctx.account)
318
+ if not entry:
319
+ ctx.mark_already()
320
+ return ExportResult(finished=False, success=not req.failed)
321
+
322
+ with contextlib.suppress(Exception):
323
+ await _invoke(ctx, functions.account.FinishTakeoutSessionRequest(success=not req.failed))
324
+ store.pop(ctx.account, None)
325
+ return ExportResult(finished=True, takeout_id=entry["takeout_id"], success=not req.failed)
326
+
327
+
328
+ SPEC_EXPORT_END = OperationSpec(
329
+ id="export.end",
330
+ request=ExportEndReq,
331
+ response=ExportResult,
332
+ impl=export_end,
333
+ summary="Close the takeout session",
334
+ description="An open session blocks the next export, so this is not optional.",
335
+ aliases=("export.finish", "daemon.takeout.finish"),
336
+ mutating=True,
337
+ idempotent=True,
338
+ surface=Surface.DAEMON,
339
+ rate_class="read",
340
+ timeout_s=60,
341
+ example={"finished": True, "takeout_id": 1234567890, "success": True},
342
+ example_args="export end",
343
+ covers_partial=("takeout.messages", "updates.takeout-session"),
344
+ coverage_note="closes the session; the data comes from the download commands.",
345
+ tags=frozenset({"agent-safe"}),
346
+ )
347
+
348
+
349
+ # ---------------------------------------------------------------------------
350
+ # export account download
351
+ # ---------------------------------------------------------------------------
352
+
353
+
354
+ class ExportAccountReq(Request):
355
+ out: Annotated[str, opt("--out", metavar="DIR", kind="path", help="Output directory.")] = (
356
+ "./telegram-export"
357
+ )
358
+ photos: Annotated[bool, opt("--photos", help="Profile photos.")] = False
359
+ sessions: Annotated[bool, opt("--sessions", help="Sessions and websites.")] = False
360
+ stories: Annotated[bool, opt("--stories", help="Story archive.")] = False
361
+ contacts: Annotated[bool, opt("--contacts", help="Contacts and top peers.")] = False
362
+ left_channels: Annotated[bool, opt("--left-channels", help="Channels I left.")] = False
363
+ everything: Annotated[bool, opt("--everything", help="All of the above.")] = True
364
+
365
+
366
+ async def export_account_download(ctx: OpContext, req: ExportAccountReq) -> ExportResult:
367
+ """Export the personal information a takeout covers.
368
+
369
+ Every call is wrapped in `invokeWithTakeout` when a session is open, and
370
+ runs normally when one is not — which is deliberately useful: the
371
+ personal-info half needs no approval delay, so it works while the message
372
+ export is still waiting for one.
373
+ """
374
+ from telethon.tl import functions
375
+
376
+ out = Path(req.out).expanduser()
377
+ out.mkdir(parents=True, exist_ok=True)
378
+ want = _wanted(req)
379
+ written: list[ExportedFile] = []
380
+ skipped: list[str] = []
381
+
382
+ jobs: list[tuple[str, str, Any]] = [
383
+ ("profile", "profile.json", functions.users.GetFullUserRequest(id="me")),
384
+ (
385
+ "photos",
386
+ "photos.json",
387
+ functions.photos.GetUserPhotosRequest(user_id="me", offset=0, max_id=0, limit=100),
388
+ ),
389
+ ("sessions", "sessions.json", functions.account.GetAuthorizationsRequest()),
390
+ ("sessions", "websites.json", functions.account.GetWebAuthorizationsRequest()),
391
+ ("contacts", "contacts.json", functions.contacts.GetSavedRequest()),
392
+ (
393
+ "left_channels",
394
+ "left_channels.json",
395
+ functions.channels.GetLeftChannelsRequest(offset=0),
396
+ ),
397
+ ]
398
+
399
+ for name, filename, request in jobs:
400
+ if name not in want:
401
+ continue
402
+ if name == "profile" and "photos" not in want and not req.everything:
403
+ continue
404
+ try:
405
+ if name == "profile":
406
+ request = functions.users.GetFullUserRequest(id=await _self(ctx))
407
+ elif name == "photos":
408
+ request = functions.photos.GetUserPhotosRequest(
409
+ user_id=await _self(ctx), offset=0, max_id=0, limit=100
410
+ )
411
+ reply = await _invoke(ctx, request)
412
+ except Exception as exc:
413
+ skipped.append(f"{filename}: {type(exc).__name__}: {exc}")
414
+ continue
415
+ written.append(_dump(out / filename, reply))
416
+
417
+ if "stories" in want:
418
+ with contextlib.suppress(Exception):
419
+ reply = await _invoke(
420
+ ctx,
421
+ functions.stories.GetStoriesArchiveRequest(
422
+ peer=await _self(ctx), offset_id=0, limit=100
423
+ ),
424
+ )
425
+ written.append(_dump(out / "stories.json", reply))
426
+
427
+ for note in skipped:
428
+ ctx.warn(note)
429
+ return ExportResult(written=len(written), files=written, out=str(out), skipped=skipped)
430
+
431
+
432
+ def _wanted(req: ExportAccountReq) -> set[str]:
433
+ chosen = {
434
+ name
435
+ for name in ("photos", "sessions", "stories", "contacts", "left_channels")
436
+ if getattr(req, name, False)
437
+ }
438
+ if chosen and not any(
439
+ getattr(req, name)
440
+ for name in ("photos", "sessions", "stories", "contacts", "left_channels")
441
+ ):
442
+ chosen = set()
443
+ if not chosen:
444
+ chosen = {"photos", "sessions", "stories", "contacts", "left_channels"}
445
+ chosen.add("profile")
446
+ return chosen
447
+
448
+
449
+ async def _self(ctx: OpContext) -> Any:
450
+ from telethon.tl import types
451
+
452
+ return types.InputUserSelf()
453
+
454
+
455
+ def _dump(path: Path, value: Any) -> ExportedFile:
456
+ from tlgr.core.tl import tl_to_builtins
457
+
458
+ body = json.dumps(tl_to_builtins(value), ensure_ascii=False, indent=2)
459
+ path.write_text(body, encoding="utf-8")
460
+ return ExportedFile(path=str(path), kind=path.stem, bytes=len(body.encode("utf-8")))
461
+
462
+
463
+ SPEC_EXPORT_ACCOUNT = OperationSpec(
464
+ id="export.account.download",
465
+ request=ExportAccountReq,
466
+ response=ExportResult,
467
+ impl=export_account_download,
468
+ summary="Export personal info: profile, photos, sessions, stories, contacts, left channels",
469
+ description=(
470
+ "Runs inside the takeout session when one is open, and normally when "
471
+ "it is not — so the personal-info half works while a message export "
472
+ "is still waiting for approval."
473
+ ),
474
+ surface=Surface.DAEMON,
475
+ rate_class="bulk",
476
+ timeout_s=900,
477
+ columns=("written", "out"),
478
+ example={"written": 5, "out": "./telegram-export"},
479
+ example_args="export account download --out ./export",
480
+ covers=("takeout.contacts", "takeout.personal-info", "updates.takeout-export-run"),
481
+ tags=frozenset({"agent-safe"}),
482
+ )
483
+
484
+
485
+ # ---------------------------------------------------------------------------
486
+ # export message download
487
+ # ---------------------------------------------------------------------------
488
+
489
+
490
+ class ExportMessagesReq(Request):
491
+ out: Annotated[str, opt("--out", metavar="DIR", kind="path", help="Output directory.")] = (
492
+ "./telegram-export"
493
+ )
494
+ chat: Annotated[
495
+ list[PeerRef],
496
+ opt("--chat", metavar="CHAT", kind="peer", help="Only these chats (repeatable)."),
497
+ ] = []
498
+ since: Annotated[
499
+ str | None, opt("--since", metavar="WHEN", kind="datetime", help="Only after this.")
500
+ ] = None
501
+ until: Annotated[
502
+ str | None, opt("--until", metavar="WHEN", kind="datetime", help="Only before this.")
503
+ ] = None
504
+ files: Annotated[bool, opt("--files", help="Also download media.")] = False
505
+ per_chat: Annotated[
506
+ int, opt("--per-chat", metavar="N", ge=1, le=100000, help="Messages per chat.")
507
+ ] = 1000
508
+
509
+
510
+ async def export_message_download(
511
+ ctx: OpContext, req: ExportMessagesReq
512
+ ) -> AsyncIterator[Page[Message]]:
513
+ """Export chat history inside the takeout session, as NDJSON on disk.
514
+
515
+ Checkpointed per chat, because a takeout still meets FLOOD_WAIT — a
516
+ generous budget is not an absent one — and an export that has to restart
517
+ from message zero after four hours is an export nobody finishes.
518
+ """
519
+ _active(ctx) # refuse to export outside a session: it would not be a takeout
520
+ out = Path(req.out).expanduser()
521
+ out.mkdir(parents=True, exist_ok=True)
522
+ client = _client(ctx)
523
+ since = parse_dt(req.since) if req.since else None
524
+ until = parse_dt(req.until) if req.until else None
525
+
526
+ if not req.chat:
527
+ raise UsageError(
528
+ "name the chats to export with --chat; exporting every dialog is a "
529
+ "different, much longer operation and should be asked for explicitly",
530
+ field="chat",
531
+ )
532
+
533
+ for ref in req.chat:
534
+ peer = await _send.resolve(ctx, ref)
535
+ chat_id = _send.peer_id_of(peer)
536
+ path = out / f"chat_{chat_id}.jsonl"
537
+ rows: list[Message] = []
538
+ with path.open("a", encoding="utf-8") as handle:
539
+ async for message in client.iter_messages(peer, limit=req.per_chat, offset_date=until):
540
+ stamp = getattr(message, "date", None)
541
+ if since is not None and stamp is not None and stamp < since:
542
+ break
543
+ model = message_to_model(message, chat_id=chat_id)
544
+ rows.append(model)
545
+ from tlgr.models.base import to_builtins
546
+
547
+ handle.write(json.dumps(to_builtins(model), ensure_ascii=False) + "\n")
548
+ if req.files:
549
+ ctx.warn(
550
+ "--files is recorded but media are not downloaded here; use "
551
+ "`tlgr media download` per message, which shares the takeout session"
552
+ )
553
+ yield build_page(
554
+ rows,
555
+ op="export.message.download",
556
+ kind=PageKind.HISTORY,
557
+ state={"chat_id": chat_id},
558
+ account=ctx.account,
559
+ has_more=False,
560
+ total=len(rows),
561
+ )
562
+
563
+
564
+ SPEC_EXPORT_MESSAGES = OperationSpec(
565
+ id="export.message.download",
566
+ request=ExportMessagesReq,
567
+ response=Page[Message],
568
+ impl=export_message_download,
569
+ summary="Export chat history inside the takeout session",
570
+ description=(
571
+ "One NDJSON file per chat, appended as it goes: a takeout still meets "
572
+ "FLOOD_WAIT, and an export that restarts from zero after four hours "
573
+ "is one nobody finishes."
574
+ ),
575
+ stream=True,
576
+ paginated=PageKind.HISTORY,
577
+ surface=Surface.DAEMON,
578
+ rate_class="bulk",
579
+ timeout_s=900,
580
+ columns=("id", "date", "text"),
581
+ empty_exit=EXIT_EMPTY,
582
+ example={
583
+ "items": [
584
+ {
585
+ "id": 12345,
586
+ "chat_id": 777123,
587
+ "date": "2026-09-03T09:14:07Z",
588
+ "date_unix": 1788340447,
589
+ }
590
+ ],
591
+ "has_more": False,
592
+ },
593
+ example_args="export message download --chat @alice --out ./export",
594
+ covers=("takeout.files", "takeout.messages"),
595
+ tags=frozenset({"agent-safe"}),
596
+ )