tlgr-cli 2.0.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- tlgr/__init__.py +3 -0
- tlgr/__main__.py +6 -0
- tlgr/actions/__init__.py +45 -0
- tlgr/actions/forward.py +74 -0
- tlgr/actions/reply.py +32 -0
- tlgr/cli/__init__.py +259 -0
- tlgr/cli/confirm.py +55 -0
- tlgr/cli/errors.py +84 -0
- tlgr/cli/gen.py +690 -0
- tlgr/cli/globals.py +273 -0
- tlgr/cli/introspect.py +170 -0
- tlgr/cli/params.py +189 -0
- tlgr/cli/render.py +418 -0
- tlgr/core/__init__.py +0 -0
- tlgr/core/accounts.py +384 -0
- tlgr/core/config.py +358 -0
- tlgr/core/custom_tl.py +170 -0
- tlgr/core/errors.py +687 -0
- tlgr/core/eventtypes.py +1170 -0
- tlgr/core/identity.py +127 -0
- tlgr/core/launchd.py +122 -0
- tlgr/core/logging.py +194 -0
- tlgr/core/media.py +134 -0
- tlgr/core/output.py +251 -0
- tlgr/core/pagination.py +227 -0
- tlgr/core/paths.py +360 -0
- tlgr/core/peers.py +427 -0
- tlgr/core/process.py +138 -0
- tlgr/core/signing.py +38 -0
- tlgr/core/systemd.py +96 -0
- tlgr/core/telethon_compat.py +295 -0
- tlgr/core/text.py +211 -0
- tlgr/core/timefmt.py +199 -0
- tlgr/core/tl.py +98 -0
- tlgr/daemon/__init__.py +0 -0
- tlgr/daemon/app.py +869 -0
- tlgr/daemon/dispatch.py +446 -0
- tlgr/daemon/events.py +723 -0
- tlgr/daemon/files.py +431 -0
- tlgr/daemon/idle.py +119 -0
- tlgr/daemon/jobs.py +68 -0
- tlgr/daemon/main.py +161 -0
- tlgr/daemon/peercred.py +75 -0
- tlgr/daemon/policy.py +113 -0
- tlgr/daemon/preauth.py +366 -0
- tlgr/daemon/ratelimit.py +391 -0
- tlgr/daemon/server.py +24 -0
- tlgr/daemon/session.py +648 -0
- tlgr/daemon/sessions.py +274 -0
- tlgr/daemon/singleton.py +114 -0
- tlgr/daemon/stream.py +193 -0
- tlgr/daemon/transfers.py +219 -0
- tlgr/daemon/webhook.py +390 -0
- tlgr/data/catalog_index.json +1 -0
- tlgr/data/parity_waivers.toml +90 -0
- tlgr/filters/__init__.py +42 -0
- tlgr/filters/compose.py +121 -0
- tlgr/filters/content.py +85 -0
- tlgr/filters/context.py +114 -0
- tlgr/filters/message.py +161 -0
- tlgr/filters/temporal.py +87 -0
- tlgr/filters/user.py +36 -0
- tlgr/gateway/__init__.py +1 -0
- tlgr/gateway/config.py +161 -0
- tlgr/gateway/engine.py +215 -0
- tlgr/gateway/event.py +22 -0
- tlgr/jobs/__init__.py +0 -0
- tlgr/jobs/base.py +81 -0
- tlgr/jobs/client.py +37 -0
- tlgr/models/__init__.py +1220 -0
- tlgr/models/admin.py +744 -0
- tlgr/models/auth.py +510 -0
- tlgr/models/base.py +81 -0
- tlgr/models/bot.py +576 -0
- tlgr/models/business.py +265 -0
- tlgr/models/call.py +586 -0
- tlgr/models/config.py +101 -0
- tlgr/models/contact.py +481 -0
- tlgr/models/daemon.py +336 -0
- tlgr/models/dialog.py +626 -0
- tlgr/models/envelope.py +68 -0
- tlgr/models/error.py +30 -0
- tlgr/models/event.py +79 -0
- tlgr/models/export.py +66 -0
- tlgr/models/gift.py +275 -0
- tlgr/models/inline.py +84 -0
- tlgr/models/location.py +115 -0
- tlgr/models/media.py +507 -0
- tlgr/models/message.py +584 -0
- tlgr/models/net.py +232 -0
- tlgr/models/notify.py +105 -0
- tlgr/models/page.py +32 -0
- tlgr/models/payment.py +172 -0
- tlgr/models/peer.py +400 -0
- tlgr/models/poll.py +119 -0
- tlgr/models/premium.py +161 -0
- tlgr/models/privacy.py +93 -0
- tlgr/models/profile.py +217 -0
- tlgr/models/reaction.py +160 -0
- tlgr/models/resolve.py +175 -0
- tlgr/models/settings.py +103 -0
- tlgr/models/stars.py +101 -0
- tlgr/models/sticker.py +243 -0
- tlgr/models/story.py +467 -0
- tlgr/models/sync.py +105 -0
- tlgr/models/todo.py +36 -0
- tlgr/models/webapp.py +89 -0
- tlgr/ops/__init__.py +63 -0
- tlgr/ops/_admin.py +313 -0
- tlgr/ops/_auth.py +599 -0
- tlgr/ops/_bots.py +586 -0
- tlgr/ops/_calls.py +535 -0
- tlgr/ops/_common.py +160 -0
- tlgr/ops/_layer.py +46 -0
- tlgr/ops/_media.py +592 -0
- tlgr/ops/_params.py +212 -0
- tlgr/ops/_rights.py +402 -0
- tlgr/ops/_send.py +593 -0
- tlgr/ops/_serialize.py +667 -0
- tlgr/ops/_settings.py +306 -0
- tlgr/ops/_spec.py +167 -0
- tlgr/ops/_story.py +743 -0
- tlgr/ops/account.py +2604 -0
- tlgr/ops/agent.py +937 -0
- tlgr/ops/auth.py +1282 -0
- tlgr/ops/bot.py +4880 -0
- tlgr/ops/business.py +1520 -0
- tlgr/ops/call.py +1610 -0
- tlgr/ops/chat.py +4025 -0
- tlgr/ops/chat_admin.py +929 -0
- tlgr/ops/chat_extra.py +1061 -0
- tlgr/ops/chat_invite.py +716 -0
- tlgr/ops/chat_manage.py +1691 -0
- tlgr/ops/chat_member.py +1357 -0
- tlgr/ops/chat_stats.py +902 -0
- tlgr/ops/chat_topic.py +905 -0
- tlgr/ops/conference.py +791 -0
- tlgr/ops/config.py +1698 -0
- tlgr/ops/contact.py +2330 -0
- tlgr/ops/daemon.py +1397 -0
- tlgr/ops/draft.py +299 -0
- tlgr/ops/emoji.py +343 -0
- tlgr/ops/events.py +1327 -0
- tlgr/ops/export.py +596 -0
- tlgr/ops/folder.py +1322 -0
- tlgr/ops/gif.py +522 -0
- tlgr/ops/gift.py +1546 -0
- tlgr/ops/giveaway.py +541 -0
- tlgr/ops/inline.py +773 -0
- tlgr/ops/job.py +799 -0
- tlgr/ops/location.py +917 -0
- tlgr/ops/media.py +4495 -0
- tlgr/ops/message.py +3769 -0
- tlgr/ops/net.py +536 -0
- tlgr/ops/notify.py +840 -0
- tlgr/ops/passport.py +464 -0
- tlgr/ops/payment.py +907 -0
- tlgr/ops/poll.py +1078 -0
- tlgr/ops/premium.py +488 -0
- tlgr/ops/privacy.py +794 -0
- tlgr/ops/profile.py +1481 -0
- tlgr/ops/proxy.py +750 -0
- tlgr/ops/reaction.py +1475 -0
- tlgr/ops/resolve.py +1140 -0
- tlgr/ops/search.py +521 -0
- tlgr/ops/settings.py +1066 -0
- tlgr/ops/stars.py +594 -0
- tlgr/ops/sticker.py +1602 -0
- tlgr/ops/story.py +3216 -0
- tlgr/ops/sync.py +788 -0
- tlgr/ops/todo.py +514 -0
- tlgr/ops/user.py +1406 -0
- tlgr/ops/vc.py +2351 -0
- tlgr/ops/webapp.py +717 -0
- tlgr/ops/webhook.py +418 -0
- tlgr/parity.py +386 -0
- tlgr/processors/__init__.py +125 -0
- tlgr/processors/regex.py +26 -0
- tlgr/processors/text.py +56 -0
- tlgr/registry.py +519 -0
- tlgr/schema.py +173 -0
- tlgr/transport/__init__.py +30 -0
- tlgr/transport/autostart.py +293 -0
- tlgr/transport/client.py +805 -0
- tlgr/transport/ndjson.py +44 -0
- tlgr/version.py +31 -0
- tlgr_cli-2.0.1.dist-info/METADATA +957 -0
- tlgr_cli-2.0.1.dist-info/RECORD +192 -0
- tlgr_cli-2.0.1.dist-info/WHEEL +5 -0
- tlgr_cli-2.0.1.dist-info/entry_points.txt +2 -0
- tlgr_cli-2.0.1.dist-info/licenses/LICENSE +21 -0
- tlgr_cli-2.0.1.dist-info/top_level.txt +1 -0
tlgr/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
|
+
)
|