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/_auth.py
ADDED
|
@@ -0,0 +1,599 @@
|
|
|
1
|
+
"""Plumbing shared by the `auth`, `account` and `passport` groups.
|
|
2
|
+
|
|
3
|
+
Four things live here because they are the same in fifteen places and getting
|
|
4
|
+
any of them wrong once is a security bug rather than a formatting one.
|
|
5
|
+
|
|
6
|
+
* **SRP.** Every `InputCheckPasswordSRP` method follows one shape: call with
|
|
7
|
+
`inputCheckPasswordEmpty`, and when the server answers
|
|
8
|
+
`PASSWORD_HASH_INVALID` ask for the password and retry. `SRP_ID_INVALID`
|
|
9
|
+
means the challenge went stale and `account.getPassword` has to be
|
|
10
|
+
refetched — a retry with the same `srp_id` fails forever. That loop is
|
|
11
|
+
written once, in `with_password()`.
|
|
12
|
+
* **Code types.** `auth.sendCode` answers with one of eleven `sentCodeType*`
|
|
13
|
+
constructors and each one asks the human to look somewhere different. The
|
|
14
|
+
mapping to a stable lowercase name is here so that `auth send-code`,
|
|
15
|
+
`auth resend-code` and `account phone set` all report it identically.
|
|
16
|
+
* **Secrets never widen.** A cloud password arrives from env/stdin/file and
|
|
17
|
+
reaches exactly one call. Nothing here logs it, stores it or puts it in a
|
|
18
|
+
model — and `PasswordState` has no field it could land in.
|
|
19
|
+
* **The daemon owns the session file.** A login opens a *pre-auth* client
|
|
20
|
+
through `daemon.preauth`, never a second `TelegramClient` on a file the
|
|
21
|
+
daemon may already hold: two live connections on one auth key is
|
|
22
|
+
`AUTH_KEY_DUPLICATED`, and Telegram revokes the session rather than
|
|
23
|
+
refusing the second one.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
import base64
|
|
29
|
+
import re
|
|
30
|
+
from datetime import datetime, timedelta, timezone
|
|
31
|
+
from typing import Any
|
|
32
|
+
|
|
33
|
+
from tlgr.core.errors import (
|
|
34
|
+
AuthPasswordRequiredError,
|
|
35
|
+
DaemonError,
|
|
36
|
+
UsageError,
|
|
37
|
+
)
|
|
38
|
+
from tlgr.core.timefmt import fmt_dt, to_unix
|
|
39
|
+
from tlgr.models.auth import Passkey, PasswordState, Session, WebSession
|
|
40
|
+
|
|
41
|
+
__all__ = [
|
|
42
|
+
"SECURE_VALUE_TYPES",
|
|
43
|
+
"accounts",
|
|
44
|
+
"app_config",
|
|
45
|
+
"client",
|
|
46
|
+
"code_settings",
|
|
47
|
+
"code_type",
|
|
48
|
+
"emit",
|
|
49
|
+
"empty_password",
|
|
50
|
+
"get_password",
|
|
51
|
+
"iso",
|
|
52
|
+
"passkey_model",
|
|
53
|
+
"password_state",
|
|
54
|
+
"preauth",
|
|
55
|
+
"resolve_alias",
|
|
56
|
+
"secure_value_type",
|
|
57
|
+
"session_model",
|
|
58
|
+
"sessions",
|
|
59
|
+
"web_session_model",
|
|
60
|
+
"with_password",
|
|
61
|
+
]
|
|
62
|
+
|
|
63
|
+
#: The URL-safe base64 alphabet, and nothing else.
|
|
64
|
+
_B64_RE = re.compile(r"[A-Za-z0-9_-]+")
|
|
65
|
+
|
|
66
|
+
#: 24 hours. `SESSION_TOO_FRESH_X` and `PASSWORD_TOO_FRESH_X` both count to
|
|
67
|
+
#: this, and neither error says so.
|
|
68
|
+
FRESHNESS_WINDOW = timedelta(hours=24)
|
|
69
|
+
|
|
70
|
+
#: The default Telegram uses when `help.getAppConfig` carries no
|
|
71
|
+
#: `authorization_autoconfirm_period`.
|
|
72
|
+
DEFAULT_AUTOCONFIRM_PERIOD = 604800
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
# ---------------------------------------------------------------------------
|
|
76
|
+
# Context accessors
|
|
77
|
+
# ---------------------------------------------------------------------------
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def client(ctx: Any) -> Any:
|
|
81
|
+
"""The connected Telethon client, or a daemon error saying why there is none."""
|
|
82
|
+
found = getattr(ctx, "client", None)
|
|
83
|
+
if found is None: # pragma: no cover - the daemon always supplies one
|
|
84
|
+
raise DaemonError("this operation needs a connected account")
|
|
85
|
+
return found
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def accounts(ctx: Any) -> Any:
|
|
89
|
+
"""The alias registry, rooted where this process's tlgr home is.
|
|
90
|
+
|
|
91
|
+
Built from `ctx.paths` in the daemon and from `$TLGR_HOME` in the CLI, so
|
|
92
|
+
a local operation and a daemon operation read the same `accounts.json`.
|
|
93
|
+
"""
|
|
94
|
+
from tlgr.core.accounts import AccountManager
|
|
95
|
+
|
|
96
|
+
base = getattr(getattr(ctx, "paths", None), "base", None)
|
|
97
|
+
return AccountManager(base)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def sessions(ctx: Any) -> Any:
|
|
101
|
+
"""The daemon's session manager — the only thing that opens a session file.
|
|
102
|
+
|
|
103
|
+
Reached through the context rather than imported, because `ops/` may not
|
|
104
|
+
import `daemon/` (§2.2). An operation that needs it and does not have it
|
|
105
|
+
is being run outside the daemon, which is a daemon error, not a crash.
|
|
106
|
+
"""
|
|
107
|
+
manager = getattr(getattr(ctx, "daemon", None), "sessions", None)
|
|
108
|
+
if manager is None:
|
|
109
|
+
raise DaemonError(
|
|
110
|
+
"this operation manages a session file, which only the daemon may open. "
|
|
111
|
+
"Start it with: tlgr daemon start"
|
|
112
|
+
)
|
|
113
|
+
return manager
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def preauth(ctx: Any) -> Any:
|
|
117
|
+
"""The daemon's pre-auth service — the only thing allowed to open a session."""
|
|
118
|
+
service = getattr(getattr(ctx, "daemon", None), "preauth", None)
|
|
119
|
+
if service is None:
|
|
120
|
+
raise DaemonError(
|
|
121
|
+
"logging in runs in the daemon, which owns the session file. "
|
|
122
|
+
"Start it with: tlgr daemon start"
|
|
123
|
+
)
|
|
124
|
+
return service
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def resolve_alias(ctx: Any, explicit: str | None = None) -> str:
|
|
128
|
+
"""Positional alias > `-a/--account` > the active account.
|
|
129
|
+
|
|
130
|
+
An operation that names an account in its request must honour that name
|
|
131
|
+
first: `tlgr account logout work` has to log *work* out even when the
|
|
132
|
+
active account is something else.
|
|
133
|
+
"""
|
|
134
|
+
from tlgr.core.errors import AccountRequiredError
|
|
135
|
+
|
|
136
|
+
if explicit:
|
|
137
|
+
from tlgr.core.paths import validate_alias
|
|
138
|
+
|
|
139
|
+
return validate_alias(explicit)
|
|
140
|
+
on_context = (getattr(ctx, "account", "") or "").strip()
|
|
141
|
+
if on_context:
|
|
142
|
+
return on_context
|
|
143
|
+
active = accounts(ctx).get_active()
|
|
144
|
+
if not active:
|
|
145
|
+
raise AccountRequiredError("no account was given and none is active")
|
|
146
|
+
return str(active)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def masked(phone: str) -> str:
|
|
150
|
+
"""`+989123456789` → `989…89`. A phone number is an identifier, not a label."""
|
|
151
|
+
digits = "".join(character for character in phone if character.isdigit())
|
|
152
|
+
if len(digits) < 6:
|
|
153
|
+
return "***"
|
|
154
|
+
return f"{digits[:3]}…{digits[-2:]}"
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def iso(value: Any) -> str | None:
|
|
158
|
+
"""Any datetime-ish thing as RFC-3339 UTC."""
|
|
159
|
+
return fmt_dt(value) if isinstance(value, datetime) else None
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
# ---------------------------------------------------------------------------
|
|
163
|
+
# Login codes
|
|
164
|
+
# ---------------------------------------------------------------------------
|
|
165
|
+
|
|
166
|
+
#: `sentCodeTypeX` → the name tlgr prints. Stable: an agent branches on it.
|
|
167
|
+
_CODE_TYPES = {
|
|
168
|
+
"SentCodeTypeApp": "app",
|
|
169
|
+
"SentCodeTypeSms": "sms",
|
|
170
|
+
"SentCodeTypeSmsWord": "sms_word",
|
|
171
|
+
"SentCodeTypeSmsPhrase": "sms_phrase",
|
|
172
|
+
"SentCodeTypeCall": "call",
|
|
173
|
+
"SentCodeTypeFlashCall": "flash_call",
|
|
174
|
+
"SentCodeTypeMissedCall": "missed_call",
|
|
175
|
+
"SentCodeTypeFragmentSms": "fragment",
|
|
176
|
+
"SentCodeTypeEmailCode": "email",
|
|
177
|
+
"SentCodeTypeSetUpEmailRequired": "setup_email_required",
|
|
178
|
+
"SentCodeTypeFirebaseSms": "firebase",
|
|
179
|
+
"CodeTypeSms": "sms",
|
|
180
|
+
"CodeTypeCall": "call",
|
|
181
|
+
"CodeTypeFlashCall": "flash_call",
|
|
182
|
+
"CodeTypeMissedCall": "missed_call",
|
|
183
|
+
"CodeTypeFragmentSms": "fragment",
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def code_type(value: Any) -> str:
|
|
188
|
+
"""The stable lowercase name of a `sentCodeType*` / `codeType*` object."""
|
|
189
|
+
if value is None:
|
|
190
|
+
return ""
|
|
191
|
+
name = type(value).__name__
|
|
192
|
+
return _CODE_TYPES.get(name, name.removeprefix("SentCodeType").removeprefix("CodeType").lower())
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def code_settings(
|
|
196
|
+
*,
|
|
197
|
+
current_number: bool = False,
|
|
198
|
+
allow_flashcall: bool = False,
|
|
199
|
+
allow_missed_call: bool = False,
|
|
200
|
+
logout_tokens: list[bytes] | None = None,
|
|
201
|
+
) -> Any:
|
|
202
|
+
"""`codeSettings`, with `allow_app_hash` always off.
|
|
203
|
+
|
|
204
|
+
`allow_app_hash` promises the server that the client can read the SMS
|
|
205
|
+
itself; a CLI cannot, and claiming otherwise makes Telegram choose a
|
|
206
|
+
delivery route nobody will ever see.
|
|
207
|
+
"""
|
|
208
|
+
from telethon.tl import types
|
|
209
|
+
|
|
210
|
+
return types.CodeSettings(
|
|
211
|
+
current_number=current_number or None,
|
|
212
|
+
allow_flashcall=allow_flashcall or None,
|
|
213
|
+
allow_missed_call=allow_missed_call or None,
|
|
214
|
+
logout_tokens=list(logout_tokens) if logout_tokens else None,
|
|
215
|
+
)
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def sent_code_fields(sent: Any) -> dict[str, Any]:
|
|
219
|
+
"""The type-specific half of a `auth.sentCode`, flattened for the model."""
|
|
220
|
+
kind = getattr(sent, "type", None)
|
|
221
|
+
fields: dict[str, Any] = {
|
|
222
|
+
"type": code_type(kind),
|
|
223
|
+
"next_type": code_type(getattr(sent, "next_type", None)) or None,
|
|
224
|
+
"timeout": getattr(sent, "timeout", None),
|
|
225
|
+
"code_hash": getattr(sent, "phone_code_hash", "") or "",
|
|
226
|
+
}
|
|
227
|
+
for name, target in (
|
|
228
|
+
("length", "length"),
|
|
229
|
+
("url", "fragment_url"),
|
|
230
|
+
("email_pattern", "email_pattern"),
|
|
231
|
+
("beginning", "beginning"),
|
|
232
|
+
("reset_available_period", "reset_available_period"),
|
|
233
|
+
):
|
|
234
|
+
value = getattr(kind, name, None)
|
|
235
|
+
if value is not None:
|
|
236
|
+
fields[target] = value
|
|
237
|
+
fields["google_signin_allowed"] = bool(getattr(kind, "google_signin_allowed", False))
|
|
238
|
+
fields["apple_signin_allowed"] = bool(getattr(kind, "apple_signin_allowed", False))
|
|
239
|
+
pending = getattr(kind, "reset_pending_date", None)
|
|
240
|
+
if pending is not None:
|
|
241
|
+
fields["reset_pending_date"] = iso(pending)
|
|
242
|
+
return fields
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
# ---------------------------------------------------------------------------
|
|
246
|
+
# The cloud password (SRP)
|
|
247
|
+
# ---------------------------------------------------------------------------
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
async def get_password(caller: Any) -> Any:
|
|
251
|
+
"""`account.getPassword` — the challenge every SRP call is built from."""
|
|
252
|
+
from telethon.tl.functions import account as fn
|
|
253
|
+
|
|
254
|
+
return await caller(fn.GetPasswordRequest())
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def empty_password() -> Any:
|
|
258
|
+
"""`inputCheckPasswordEmpty`: "I have no password", not "I forgot it"."""
|
|
259
|
+
from telethon.tl import types
|
|
260
|
+
|
|
261
|
+
return types.InputCheckPasswordEmpty()
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def _srp(state: Any, secret: str | None) -> Any:
|
|
265
|
+
if secret is None:
|
|
266
|
+
return empty_password()
|
|
267
|
+
from telethon import password as srp_module
|
|
268
|
+
|
|
269
|
+
return srp_module.compute_check(state, secret)
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
async def with_password(caller: Any, build: Any, secret: str | None, *, state: Any = None) -> Any:
|
|
273
|
+
"""Run an `InputCheckPasswordSRP` request, refetching the challenge once.
|
|
274
|
+
|
|
275
|
+
*build* takes the computed check object and returns the request. The
|
|
276
|
+
retry exists because `srp_id` is single-use: a second attempt with a
|
|
277
|
+
stale one answers `SRP_ID_INVALID` forever, and the only cure is a fresh
|
|
278
|
+
`account.getPassword`.
|
|
279
|
+
"""
|
|
280
|
+
current = state if state is not None else await get_password(caller)
|
|
281
|
+
try:
|
|
282
|
+
return await caller(build(_srp(current, secret)))
|
|
283
|
+
except Exception as exc:
|
|
284
|
+
message = str(exc)
|
|
285
|
+
if "SRP_ID_INVALID" in message or "SRP_PASSWORD_CHANGED" in message:
|
|
286
|
+
refreshed = await get_password(caller)
|
|
287
|
+
return await caller(build(_srp(refreshed, secret)))
|
|
288
|
+
if "PASSWORD_HASH_INVALID" in message and secret is None:
|
|
289
|
+
raise AuthPasswordRequiredError(
|
|
290
|
+
"this account has a cloud password; supply it with "
|
|
291
|
+
"--password-env TLGR_2FA_PASSWORD (or --password-stdin/--password-file)"
|
|
292
|
+
) from exc
|
|
293
|
+
raise
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def new_password_settings(
|
|
297
|
+
state: Any,
|
|
298
|
+
*,
|
|
299
|
+
new_password: str | None = None,
|
|
300
|
+
hint: str | None = None,
|
|
301
|
+
email: str | None = None,
|
|
302
|
+
) -> Any:
|
|
303
|
+
"""`passwordInputSettings` for set / change / remove.
|
|
304
|
+
|
|
305
|
+
Removing is the same call with an empty `new_password_hash`, which is why
|
|
306
|
+
there is no separate builder for it.
|
|
307
|
+
"""
|
|
308
|
+
from telethon import password as srp_module
|
|
309
|
+
from telethon.tl.types import account as account_types
|
|
310
|
+
|
|
311
|
+
if new_password:
|
|
312
|
+
digest = srp_module.compute_digest(state.new_algo, new_password)
|
|
313
|
+
return account_types.PasswordInputSettings(
|
|
314
|
+
new_algo=state.new_algo,
|
|
315
|
+
new_password_hash=digest,
|
|
316
|
+
hint=hint or "",
|
|
317
|
+
email=email or None,
|
|
318
|
+
)
|
|
319
|
+
if hint is not None and new_password is None and email is None:
|
|
320
|
+
return account_types.PasswordInputSettings(hint=hint)
|
|
321
|
+
if email is not None and new_password is None:
|
|
322
|
+
return account_types.PasswordInputSettings(email=email)
|
|
323
|
+
# No new password: turn 2FA off.
|
|
324
|
+
return account_types.PasswordInputSettings(
|
|
325
|
+
new_algo=None, new_password_hash=b"", hint="", email=None
|
|
326
|
+
)
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
def password_state(state: Any, settings: Any = None) -> PasswordState:
|
|
330
|
+
"""`account.Password` as the model, without a byte of SRP material."""
|
|
331
|
+
created = getattr(state, "pending_reset_date", None)
|
|
332
|
+
return PasswordState(
|
|
333
|
+
has_password=bool(getattr(state, "has_password", False)),
|
|
334
|
+
has_recovery=bool(getattr(state, "has_recovery", False)),
|
|
335
|
+
has_secure_values=bool(getattr(state, "has_secure_values", False)),
|
|
336
|
+
hint=getattr(state, "hint", None) or None,
|
|
337
|
+
email_unconfirmed_pattern=getattr(state, "email_unconfirmed_pattern", None) or None,
|
|
338
|
+
login_email_pattern=getattr(state, "login_email_pattern", None) or None,
|
|
339
|
+
pending_reset_date=iso(created),
|
|
340
|
+
recovery_email=getattr(settings, "email", None) if settings is not None else None,
|
|
341
|
+
)
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
# ---------------------------------------------------------------------------
|
|
345
|
+
# Sessions
|
|
346
|
+
# ---------------------------------------------------------------------------
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
async def app_config(caller: Any) -> dict[str, Any]:
|
|
350
|
+
"""`help.getAppConfig` as a plain dict, or `{}` when it cannot be read.
|
|
351
|
+
|
|
352
|
+
Empty rather than raising: every caller uses it for a *derived* field
|
|
353
|
+
(the auto-confirm deadline, a freeze appeal URL), and losing a derived
|
|
354
|
+
field is not worth failing the command the user actually asked for.
|
|
355
|
+
"""
|
|
356
|
+
from telethon.tl.functions import help as fn
|
|
357
|
+
|
|
358
|
+
try:
|
|
359
|
+
answer = await caller(fn.GetAppConfigRequest(hash=0))
|
|
360
|
+
except Exception:
|
|
361
|
+
return {}
|
|
362
|
+
return _json_value(getattr(answer, "config", None)) or {}
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
def _json_value(node: Any) -> Any:
|
|
366
|
+
"""A `JSONValue` tree as builtins."""
|
|
367
|
+
name = type(node).__name__
|
|
368
|
+
if name == "JsonObject":
|
|
369
|
+
return {item.key: _json_value(item.value) for item in node.value}
|
|
370
|
+
if name == "JsonArray":
|
|
371
|
+
return [_json_value(item) for item in node.value]
|
|
372
|
+
if name in ("JsonString", "JsonNumber", "JsonBool"):
|
|
373
|
+
return node.value
|
|
374
|
+
if name == "JsonNull":
|
|
375
|
+
return None
|
|
376
|
+
return None
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
def session_model(
|
|
380
|
+
auth: Any, *, ttl_days: int | None = None, autoconfirm_period: int = 0
|
|
381
|
+
) -> Session:
|
|
382
|
+
"""One `authorization` as a row, with the two deadlines nobody can guess.
|
|
383
|
+
|
|
384
|
+
`deny_deadline` is when an unconfirmed login stops being deniable
|
|
385
|
+
(Telegram confirms it for you), and `sensitive_actions_eligible_at` is
|
|
386
|
+
what `SESSION_TOO_FRESH_X` counts down to.
|
|
387
|
+
"""
|
|
388
|
+
created = getattr(auth, "date_created", None)
|
|
389
|
+
period = autoconfirm_period or DEFAULT_AUTOCONFIRM_PERIOD
|
|
390
|
+
deny = created + timedelta(seconds=period) if isinstance(created, datetime) else None
|
|
391
|
+
eligible = created + FRESHNESS_WINDOW if isinstance(created, datetime) else None
|
|
392
|
+
return Session(
|
|
393
|
+
hash=str(getattr(auth, "hash", 0)),
|
|
394
|
+
current=bool(getattr(auth, "current", False)),
|
|
395
|
+
official_app=bool(getattr(auth, "official_app", False)),
|
|
396
|
+
unconfirmed=bool(getattr(auth, "unconfirmed", False)),
|
|
397
|
+
password_pending=bool(getattr(auth, "password_pending", False)),
|
|
398
|
+
app_name=getattr(auth, "app_name", "") or "",
|
|
399
|
+
app_version=getattr(auth, "app_version", "") or "",
|
|
400
|
+
api_id=getattr(auth, "api_id", None),
|
|
401
|
+
device_model=getattr(auth, "device_model", "") or "",
|
|
402
|
+
platform=getattr(auth, "platform", "") or "",
|
|
403
|
+
system_version=getattr(auth, "system_version", "") or "",
|
|
404
|
+
ip=getattr(auth, "ip", "") or "",
|
|
405
|
+
country=getattr(auth, "country", "") or "",
|
|
406
|
+
region=getattr(auth, "region", "") or "",
|
|
407
|
+
date_created=iso(created),
|
|
408
|
+
date_active=iso(getattr(auth, "date_active", None)),
|
|
409
|
+
call_requests_disabled=bool(getattr(auth, "call_requests_disabled", False)),
|
|
410
|
+
encrypted_requests_disabled=bool(getattr(auth, "encrypted_requests_disabled", False)),
|
|
411
|
+
deny_deadline=iso(deny) if getattr(auth, "unconfirmed", False) else None,
|
|
412
|
+
sensitive_actions_eligible_at=iso(eligible),
|
|
413
|
+
ttl_days=ttl_days,
|
|
414
|
+
)
|
|
415
|
+
|
|
416
|
+
|
|
417
|
+
def web_session_model(auth: Any, users: dict[int, Any] | None = None) -> WebSession:
|
|
418
|
+
bot_id = getattr(auth, "bot_id", None)
|
|
419
|
+
bot = (users or {}).get(int(bot_id)) if bot_id else None
|
|
420
|
+
return WebSession(
|
|
421
|
+
hash=str(getattr(auth, "hash", 0)),
|
|
422
|
+
bot=int(bot_id) if bot_id else None,
|
|
423
|
+
bot_username=getattr(bot, "username", None),
|
|
424
|
+
domain=getattr(auth, "domain", "") or "",
|
|
425
|
+
browser=getattr(auth, "browser", "") or "",
|
|
426
|
+
platform=getattr(auth, "platform", "") or "",
|
|
427
|
+
ip=getattr(auth, "ip", "") or "",
|
|
428
|
+
region=getattr(auth, "region", "") or "",
|
|
429
|
+
date_created=iso(getattr(auth, "date_created", None)),
|
|
430
|
+
date_active=iso(getattr(auth, "date_active", None)),
|
|
431
|
+
)
|
|
432
|
+
|
|
433
|
+
|
|
434
|
+
def passkey_model(raw: Any) -> Passkey:
|
|
435
|
+
created = getattr(raw, "date", None)
|
|
436
|
+
return Passkey(
|
|
437
|
+
id=str(getattr(raw, "id", "")),
|
|
438
|
+
name=getattr(raw, "name", "") or "",
|
|
439
|
+
date=iso(created),
|
|
440
|
+
date_unix=to_unix(created) if isinstance(created, datetime) else None,
|
|
441
|
+
last_usage_date=iso(getattr(raw, "last_usage_date", None)),
|
|
442
|
+
software_emoji_id=getattr(raw, "software_emoji_id", None),
|
|
443
|
+
)
|
|
444
|
+
|
|
445
|
+
|
|
446
|
+
def parse_hash(value: str, *, field: str = "hash") -> int:
|
|
447
|
+
"""A session hash is a signed 64-bit integer, however it was typed."""
|
|
448
|
+
try:
|
|
449
|
+
return int(str(value).strip())
|
|
450
|
+
except (TypeError, ValueError) as exc:
|
|
451
|
+
raise UsageError(f"{value!r} is not a session hash", field=field) from exc
|
|
452
|
+
|
|
453
|
+
|
|
454
|
+
def now() -> datetime:
|
|
455
|
+
return datetime.now(timezone.utc)
|
|
456
|
+
|
|
457
|
+
|
|
458
|
+
# ---------------------------------------------------------------------------
|
|
459
|
+
# Passport
|
|
460
|
+
# ---------------------------------------------------------------------------
|
|
461
|
+
|
|
462
|
+
#: The thirteen `secureValueType*` constructors, by the name a user types.
|
|
463
|
+
SECURE_VALUE_TYPES: dict[str, str] = {
|
|
464
|
+
"personal_details": "SecureValueTypePersonalDetails",
|
|
465
|
+
"passport": "SecureValueTypePassport",
|
|
466
|
+
"driver_license": "SecureValueTypeDriverLicense",
|
|
467
|
+
"identity_card": "SecureValueTypeIdentityCard",
|
|
468
|
+
"internal_passport": "SecureValueTypeInternalPassport",
|
|
469
|
+
"address": "SecureValueTypeAddress",
|
|
470
|
+
"utility_bill": "SecureValueTypeUtilityBill",
|
|
471
|
+
"bank_statement": "SecureValueTypeBankStatement",
|
|
472
|
+
"rental_agreement": "SecureValueTypeRentalAgreement",
|
|
473
|
+
"passport_registration": "SecureValueTypePassportRegistration",
|
|
474
|
+
"temporary_registration": "SecureValueTypeTemporaryRegistration",
|
|
475
|
+
"phone": "SecureValueTypePhone",
|
|
476
|
+
"email": "SecureValueTypeEmail",
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
|
|
480
|
+
def secure_value_name(value: Any) -> str:
|
|
481
|
+
"""`SecureValueTypeDriverLicense` → `driver_license`."""
|
|
482
|
+
raw = type(value).__name__.removeprefix("SecureValueType")
|
|
483
|
+
out = []
|
|
484
|
+
for index, character in enumerate(raw):
|
|
485
|
+
if character.isupper() and index:
|
|
486
|
+
out.append("_")
|
|
487
|
+
out.append(character.lower())
|
|
488
|
+
return "".join(out)
|
|
489
|
+
|
|
490
|
+
|
|
491
|
+
def secure_value_type(name: str) -> Any:
|
|
492
|
+
"""The constructor for a document type a user named on the command line."""
|
|
493
|
+
from telethon.tl import types
|
|
494
|
+
|
|
495
|
+
key = name.strip().lower().replace("-", "_")
|
|
496
|
+
class_name = SECURE_VALUE_TYPES.get(key)
|
|
497
|
+
if class_name is None:
|
|
498
|
+
raise UsageError(
|
|
499
|
+
f"unknown Passport document type {name!r}; "
|
|
500
|
+
f"one of: {', '.join(sorted(SECURE_VALUE_TYPES))}",
|
|
501
|
+
field="type",
|
|
502
|
+
)
|
|
503
|
+
return getattr(types, class_name)()
|
|
504
|
+
|
|
505
|
+
|
|
506
|
+
def b64(data: bytes | None) -> str:
|
|
507
|
+
"""URL-safe base64 without padding — how Telegram spells a login token."""
|
|
508
|
+
if not data:
|
|
509
|
+
return ""
|
|
510
|
+
return base64.urlsafe_b64encode(data).decode("ascii").rstrip("=")
|
|
511
|
+
|
|
512
|
+
|
|
513
|
+
def unb64(text: str) -> bytes:
|
|
514
|
+
"""The inverse, tolerant of the padding a copy-paste may have dropped."""
|
|
515
|
+
cleaned = text.strip()
|
|
516
|
+
if "token=" in cleaned:
|
|
517
|
+
cleaned = cleaned.split("token=", 1)[1].split("&", 1)[0]
|
|
518
|
+
# `urlsafe_b64decode` has no `validate=`, and the padded decoder silently
|
|
519
|
+
# drops anything outside the alphabet — so `token=!!!!` would decode to
|
|
520
|
+
# empty bytes and be sent to Telegram as a login token. Check first.
|
|
521
|
+
if not cleaned or not _B64_RE.fullmatch(cleaned):
|
|
522
|
+
raise UsageError(f"{text!r} is not a tg://login token", field="link")
|
|
523
|
+
padding = "=" * (-len(cleaned) % 4)
|
|
524
|
+
try:
|
|
525
|
+
return base64.urlsafe_b64decode(cleaned + padding)
|
|
526
|
+
except Exception as exc:
|
|
527
|
+
raise UsageError(f"{text!r} is not a tg://login token", field="link") from exc
|
|
528
|
+
|
|
529
|
+
|
|
530
|
+
# ---------------------------------------------------------------------------
|
|
531
|
+
# Future auth tokens
|
|
532
|
+
# ---------------------------------------------------------------------------
|
|
533
|
+
#
|
|
534
|
+
# `auth.loggedOut.future_auth_token` lets the same device log back in without
|
|
535
|
+
# a code. It is a bearer credential: 0600 next to the session, capped at 20
|
|
536
|
+
# (Telegram's own limit), and dropped when the account is removed.
|
|
537
|
+
|
|
538
|
+
TOKEN_FILE = "future-auth-tokens"
|
|
539
|
+
MAX_TOKENS = 20
|
|
540
|
+
|
|
541
|
+
|
|
542
|
+
def token_path(manager: Any, alias: str) -> Any:
|
|
543
|
+
return manager.paths.account_dir(alias) / TOKEN_FILE
|
|
544
|
+
|
|
545
|
+
|
|
546
|
+
def future_tokens(manager: Any, alias: str) -> list[bytes]:
|
|
547
|
+
"""The stored tokens, newest last. Unreadable or absent means none."""
|
|
548
|
+
path = token_path(manager, alias)
|
|
549
|
+
if not path.exists():
|
|
550
|
+
return []
|
|
551
|
+
try:
|
|
552
|
+
lines = path.read_text(encoding="utf-8").split()
|
|
553
|
+
except OSError:
|
|
554
|
+
return []
|
|
555
|
+
out: list[bytes] = []
|
|
556
|
+
for line in lines[-MAX_TOKENS:]:
|
|
557
|
+
try:
|
|
558
|
+
out.append(unb64(line))
|
|
559
|
+
except Exception:
|
|
560
|
+
continue
|
|
561
|
+
return out
|
|
562
|
+
|
|
563
|
+
|
|
564
|
+
def store_future_token(manager: Any, alias: str, token: bytes | None) -> bool:
|
|
565
|
+
"""Append a token at 0600, evicting past the cap. Returns whether it stored."""
|
|
566
|
+
if not token:
|
|
567
|
+
return False
|
|
568
|
+
from tlgr.core.paths import write_private
|
|
569
|
+
|
|
570
|
+
path = token_path(manager, alias)
|
|
571
|
+
existing = path.read_text(encoding="utf-8").split() if path.exists() else []
|
|
572
|
+
write_private(path, "\n".join([*existing, b64(token)][-MAX_TOKENS:]))
|
|
573
|
+
return True
|
|
574
|
+
|
|
575
|
+
|
|
576
|
+
def drop_future_tokens(manager: Any, alias: str) -> None:
|
|
577
|
+
import contextlib
|
|
578
|
+
|
|
579
|
+
with contextlib.suppress(OSError):
|
|
580
|
+
token_path(manager, alias).unlink(missing_ok=True)
|
|
581
|
+
|
|
582
|
+
|
|
583
|
+
def already(ctx: Any) -> None:
|
|
584
|
+
"""Flag `meta.already`: the world already looked the way the caller asked."""
|
|
585
|
+
mark = getattr(ctx, "mark_already", None)
|
|
586
|
+
if callable(mark):
|
|
587
|
+
mark()
|
|
588
|
+
|
|
589
|
+
|
|
590
|
+
def emit(ctx: Any, event_type: str, payload: dict[str, Any]) -> None:
|
|
591
|
+
"""Echo an action onto the event bus, unless this op runs account-less.
|
|
592
|
+
|
|
593
|
+
The login and account-management operations take the alias in the
|
|
594
|
+
request rather than in `-a`, so `ctx.account` is empty for them — and the
|
|
595
|
+
bus keys every event by a *valid* alias. Emitting anyway turns a
|
|
596
|
+
successful logout into a USAGE error about an empty alias.
|
|
597
|
+
"""
|
|
598
|
+
if (getattr(ctx, "account", "") or "").strip():
|
|
599
|
+
ctx.emit(event_type, payload)
|