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,1282 @@
|
|
|
1
|
+
"""The `auth` group: logging in without a human holding the terminal open.
|
|
2
|
+
|
|
3
|
+
v1's `account add` was one interactive process that sent a code, blocked on
|
|
4
|
+
`input()` while a person read their phone, and signed in from the same
|
|
5
|
+
`TelegramClient` — because Telethon keeps `phone_code_hash` in memory on the
|
|
6
|
+
client object. That shape cannot be scripted: an agent has no `input()`, and a
|
|
7
|
+
second process has lost the hash.
|
|
8
|
+
|
|
9
|
+
Here the login is a **sequence of ordinary commands**. The pending client and
|
|
10
|
+
its `phone_code_hash` live in the daemon (which owns the session file), and
|
|
11
|
+
`<account>/login-state.json` mirrors them at 0600 so the steps survive a
|
|
12
|
+
daemon restart:
|
|
13
|
+
|
|
14
|
+
tlgr auth send-code +989… # → {"type": "app", "code_hash": "…"}
|
|
15
|
+
tlgr auth verify-code 12345 --password-env TLGR_2FA_PASSWORD
|
|
16
|
+
|
|
17
|
+
Every terminal state is a *state*, not a stack trace: `authorized`,
|
|
18
|
+
`password_required` (exit 4 — supply a password source and re-run), and
|
|
19
|
+
`signup_required` (a different command, deliberately).
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import asyncio
|
|
25
|
+
import re
|
|
26
|
+
import time
|
|
27
|
+
from datetime import timedelta
|
|
28
|
+
from typing import Annotated, Any
|
|
29
|
+
|
|
30
|
+
from tlgr.core.errors import (
|
|
31
|
+
AuthenticationError,
|
|
32
|
+
AuthPasswordRequiredError,
|
|
33
|
+
ConfigurationError,
|
|
34
|
+
NotSupportedError,
|
|
35
|
+
UsageError,
|
|
36
|
+
)
|
|
37
|
+
from tlgr.core.paths import validate_alias
|
|
38
|
+
from tlgr.core.timefmt import parse_duration
|
|
39
|
+
from tlgr.models.auth import (
|
|
40
|
+
AccountDeletion,
|
|
41
|
+
AutologinUrl,
|
|
42
|
+
LoginCodes,
|
|
43
|
+
LoginEmail,
|
|
44
|
+
LoginResult,
|
|
45
|
+
QrLogin,
|
|
46
|
+
SentCode,
|
|
47
|
+
Terms,
|
|
48
|
+
)
|
|
49
|
+
from tlgr.models.base import Request
|
|
50
|
+
from tlgr.models.page import Page
|
|
51
|
+
from tlgr.ops import _auth
|
|
52
|
+
from tlgr.ops._params import arg, opt
|
|
53
|
+
from tlgr.ops._spec import OpContext, OperationSpec
|
|
54
|
+
|
|
55
|
+
__all__ = [
|
|
56
|
+
"SPEC_AUTOLOGIN_URL_GET",
|
|
57
|
+
"SPEC_CODE_LIST",
|
|
58
|
+
"SPEC_LOGIN_EMAIL_SET",
|
|
59
|
+
"SPEC_QR",
|
|
60
|
+
"SPEC_RECOVER",
|
|
61
|
+
"SPEC_RESEND_CODE",
|
|
62
|
+
"SPEC_RESET_ACCOUNT",
|
|
63
|
+
"SPEC_SEND_CODE",
|
|
64
|
+
"SPEC_SIGN_UP",
|
|
65
|
+
"SPEC_TOS",
|
|
66
|
+
"SPEC_VERIFY_CODE",
|
|
67
|
+
]
|
|
68
|
+
|
|
69
|
+
#: Telegram's own service account. Login codes for *this* account arrive here,
|
|
70
|
+
#: which is what lets one tlgr account onboard another unattended.
|
|
71
|
+
SERVICE_CHAT = 777000
|
|
72
|
+
|
|
73
|
+
#: A login code as it appears in a 777000 message.
|
|
74
|
+
_CODE_RE = re.compile(r"\b(\d{5,7})\b")
|
|
75
|
+
|
|
76
|
+
#: `t.me/login/12345` and `tg://login?code=12345` are both a code, pasted.
|
|
77
|
+
_CODE_LINK_RE = re.compile(r"(?:t\.me/login/|[?&]code=)([A-Za-z0-9_-]+)")
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
# ---------------------------------------------------------------------------
|
|
81
|
+
# Shared helpers
|
|
82
|
+
# ---------------------------------------------------------------------------
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _default_alias(phone: str) -> str:
|
|
86
|
+
"""The last six digits of the number, which is what v1 chose."""
|
|
87
|
+
digits = "".join(character for character in phone if character.isdigit())
|
|
88
|
+
return digits[-6:] or "default"
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _login_alias(ctx: OpContext, service: Any, explicit: str | None = None) -> str:
|
|
92
|
+
"""Which account a login step is about.
|
|
93
|
+
|
|
94
|
+
`--alias` wins, then `-a/--account`, then the one login already in
|
|
95
|
+
progress, then the active account. Beyond that it refuses to guess:
|
|
96
|
+
guessing which account a code belongs to is how the wrong session gets
|
|
97
|
+
logged out.
|
|
98
|
+
"""
|
|
99
|
+
if explicit:
|
|
100
|
+
return validate_alias(explicit)
|
|
101
|
+
named = (getattr(ctx, "account", "") or "").strip()
|
|
102
|
+
if named:
|
|
103
|
+
return validate_alias(named)
|
|
104
|
+
in_progress = [
|
|
105
|
+
alias for alias in list(getattr(service, "_pending", {})) if service.pending(alias)
|
|
106
|
+
]
|
|
107
|
+
if len(in_progress) == 1:
|
|
108
|
+
return str(in_progress[0])
|
|
109
|
+
if not in_progress:
|
|
110
|
+
active = _auth.accounts(ctx).get_active()
|
|
111
|
+
if active:
|
|
112
|
+
return validate_alias(str(active))
|
|
113
|
+
raise UsageError(
|
|
114
|
+
f"which account? pass --alias (or -a): {len(in_progress)} logins are in progress",
|
|
115
|
+
field="alias",
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
async def _caller(ctx: OpContext, service: Any, alias: str) -> Any:
|
|
120
|
+
"""The client to talk to: the pending login's, else the live session's.
|
|
121
|
+
|
|
122
|
+
`auth recover` is the one flow that runs on both sides of a login — the
|
|
123
|
+
RPCs are identical whether the password was forgotten at the login screen
|
|
124
|
+
or three months into a session.
|
|
125
|
+
"""
|
|
126
|
+
pending = service.pending(alias)
|
|
127
|
+
if pending is not None and pending.client is not None:
|
|
128
|
+
return pending.client
|
|
129
|
+
session = await _auth.sessions(ctx).ensure(alias)
|
|
130
|
+
return await session.acquire(timeout=60)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _credentials(
|
|
134
|
+
ctx: OpContext, alias: str, api_id: int | None, api_hash: str | None
|
|
135
|
+
) -> tuple[int, str]:
|
|
136
|
+
"""`(api_id, api_hash)` from the flags, the account, the env or the config."""
|
|
137
|
+
import os
|
|
138
|
+
|
|
139
|
+
manager = _auth.accounts(ctx)
|
|
140
|
+
stored_id, stored_hash = (None, None)
|
|
141
|
+
if manager.get_account(alias) is not None:
|
|
142
|
+
stored_id, stored_hash = manager.load_credentials(alias)
|
|
143
|
+
resolved_id = api_id or stored_id
|
|
144
|
+
resolved_hash = api_hash or stored_hash
|
|
145
|
+
if not resolved_id:
|
|
146
|
+
env = os.environ.get("TLGR_API_ID") or os.environ.get("TELEGRAM_API_ID")
|
|
147
|
+
resolved_id = int(env) if env and env.isdigit() else None
|
|
148
|
+
if not resolved_hash:
|
|
149
|
+
resolved_hash = os.environ.get("TELEGRAM_API_HASH") or None
|
|
150
|
+
if not resolved_id or not resolved_hash:
|
|
151
|
+
raise ConfigurationError(
|
|
152
|
+
"this account has no API credentials. Get them from my.telegram.org and pass "
|
|
153
|
+
"--api-id with --api-hash-env (never on the command line)."
|
|
154
|
+
)
|
|
155
|
+
return int(resolved_id), str(resolved_hash)
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _pending_login(service: Any, alias: str, phone: str, code_hash: str) -> tuple[str, str]:
|
|
159
|
+
"""The phone and hash a step should use: the flags, else the stored state."""
|
|
160
|
+
pending = service.pending(alias)
|
|
161
|
+
state = service.read_state(alias)
|
|
162
|
+
resolved_phone = phone or (pending.phone if pending else "") or str(state.get("phone", ""))
|
|
163
|
+
resolved_hash = (
|
|
164
|
+
code_hash
|
|
165
|
+
or (pending.phone_code_hash if pending else "")
|
|
166
|
+
or str(state.get("phone_code_hash", ""))
|
|
167
|
+
)
|
|
168
|
+
if not resolved_phone or not resolved_hash:
|
|
169
|
+
raise UsageError(
|
|
170
|
+
f"no login is in progress for {alias!r}. Run: tlgr auth send-code <phone>",
|
|
171
|
+
field="alias",
|
|
172
|
+
)
|
|
173
|
+
return resolved_phone, resolved_hash
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
async def _authorized(ctx: OpContext, service: Any, alias: str, result: Any) -> LoginResult:
|
|
177
|
+
"""Turn an `auth.authorization` into the model, and hand over the session."""
|
|
178
|
+
_auth.store_future_token(_auth.accounts(ctx), alias, getattr(result, "future_auth_token", None))
|
|
179
|
+
finished = await service.finish(alias)
|
|
180
|
+
return LoginResult(
|
|
181
|
+
status="authorized",
|
|
182
|
+
alias=alias,
|
|
183
|
+
user_id=finished.get("user_id"),
|
|
184
|
+
username=finished.get("username"),
|
|
185
|
+
setup_password_required=bool(getattr(result, "setup_password_required", False)),
|
|
186
|
+
otherwise_relogin_days=getattr(result, "otherwise_relogin_days", None),
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
# ---------------------------------------------------------------------------
|
|
191
|
+
# auth send-code
|
|
192
|
+
# ---------------------------------------------------------------------------
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
class SendCodeReq(Request):
|
|
196
|
+
phone: Annotated[str, arg(0, metavar="PHONE", help="The number to log in with.")]
|
|
197
|
+
alias: Annotated[
|
|
198
|
+
str | None,
|
|
199
|
+
opt("--alias", help="Account alias to create or resume (default: the last 6 digits)."),
|
|
200
|
+
] = None
|
|
201
|
+
api_id: Annotated[
|
|
202
|
+
int | None, opt("--api-id", metavar="ID", help="api_id; else TLGR_API_ID, then the config.")
|
|
203
|
+
] = None
|
|
204
|
+
api_hash: Annotated[
|
|
205
|
+
str | None,
|
|
206
|
+
opt(secret=True, envvar="TLGR_API_HASH", help="api_hash — never on the command line."),
|
|
207
|
+
] = None
|
|
208
|
+
current_number: Annotated[
|
|
209
|
+
bool, opt("--current-number", help="codeSettings.current_number: this device owns it.")
|
|
210
|
+
] = False
|
|
211
|
+
allow_missed_call: Annotated[
|
|
212
|
+
bool, opt("--allow-missed-call", help="Permit a missed-call code (read the caller id).")
|
|
213
|
+
] = False
|
|
214
|
+
allow_flashcall: Annotated[
|
|
215
|
+
bool, opt("--allow-flashcall", help="Permit a flash-call code; you type the number.")
|
|
216
|
+
] = False
|
|
217
|
+
no_future_tokens: Annotated[
|
|
218
|
+
bool, opt("--no-future-tokens", help="Do not offer stored tokens; force a real code.")
|
|
219
|
+
] = False
|
|
220
|
+
recaptcha_token: Annotated[
|
|
221
|
+
str | None, opt("--recaptcha-token", metavar="TOKEN", help="A reCAPTCHA token you solved.")
|
|
222
|
+
] = None
|
|
223
|
+
test_dc: Annotated[bool, opt("--test-dc", help="Log in against the Telegram test DCs.")] = False
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
async def send_code(ctx: OpContext, req: SendCodeReq) -> SentCode:
|
|
227
|
+
"""Ask Telegram to send a login code, and remember what it answered.
|
|
228
|
+
|
|
229
|
+
The `phone_code_hash` is written to `login-state.json` (0600) rather than
|
|
230
|
+
kept in this process, which is the whole reason `auth verify-code` can be
|
|
231
|
+
a separate command run half an hour later by something else.
|
|
232
|
+
|
|
233
|
+
A third-party `api_id` usually gets `sentCodeTypeApp` — the code arrives
|
|
234
|
+
in another logged-in Telegram session, not by SMS — so the type is
|
|
235
|
+
reported verbatim instead of being described as "we texted you".
|
|
236
|
+
"""
|
|
237
|
+
from telethon.tl.functions import auth as fn
|
|
238
|
+
|
|
239
|
+
if req.recaptcha_token:
|
|
240
|
+
raise NotSupportedError(
|
|
241
|
+
"invokeWithReCaptcha is layer 229 and the pinned Telethon speaks 227; "
|
|
242
|
+
"complete the challenge in an official client instead"
|
|
243
|
+
)
|
|
244
|
+
service = _auth.preauth(ctx)
|
|
245
|
+
alias = validate_alias(req.alias or _default_alias(req.phone))
|
|
246
|
+
api_id, api_hash = _credentials(ctx, alias, req.api_id, req.api_hash)
|
|
247
|
+
|
|
248
|
+
manager = _auth.accounts(ctx)
|
|
249
|
+
if manager.get_account(alias) is None:
|
|
250
|
+
manager.add_account(alias)
|
|
251
|
+
manager.save_credentials(api_id, api_hash, alias)
|
|
252
|
+
|
|
253
|
+
client = await service.client_for(alias, api_id=api_id, api_hash=api_hash)
|
|
254
|
+
tokens = [] if req.no_future_tokens else _auth.future_tokens(manager, alias)
|
|
255
|
+
settings = _auth.code_settings(
|
|
256
|
+
current_number=req.current_number,
|
|
257
|
+
allow_flashcall=req.allow_flashcall,
|
|
258
|
+
allow_missed_call=req.allow_missed_call,
|
|
259
|
+
logout_tokens=tokens,
|
|
260
|
+
)
|
|
261
|
+
sent = await client(
|
|
262
|
+
fn.SendCodeRequest(
|
|
263
|
+
phone_number=req.phone, api_id=api_id, api_hash=api_hash, settings=settings
|
|
264
|
+
)
|
|
265
|
+
)
|
|
266
|
+
if type(sent).__name__ == "SentCodeSuccess":
|
|
267
|
+
# A stored future auth token matched: there is no code to type.
|
|
268
|
+
await _authorized(ctx, service, alias, getattr(sent, "authorization", None))
|
|
269
|
+
return SentCode(phone=_auth.masked(req.phone), account=alias, already=True, type="token")
|
|
270
|
+
|
|
271
|
+
fields = _auth.sent_code_fields(sent)
|
|
272
|
+
service.remember(
|
|
273
|
+
alias,
|
|
274
|
+
phone=req.phone,
|
|
275
|
+
phone_code_hash=fields["code_hash"],
|
|
276
|
+
code_type=fields["type"],
|
|
277
|
+
test_dc=req.test_dc,
|
|
278
|
+
)
|
|
279
|
+
return SentCode(phone=_auth.masked(req.phone), account=alias, **fields)
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
SPEC_SEND_CODE = OperationSpec(
|
|
283
|
+
id="auth.send-code",
|
|
284
|
+
request=SendCodeReq,
|
|
285
|
+
response=SentCode,
|
|
286
|
+
impl=send_code,
|
|
287
|
+
summary="Start a phone login: ask Telegram to send a login code",
|
|
288
|
+
description=(
|
|
289
|
+
"Writes the pending login (phone + phone_code_hash + code type) to "
|
|
290
|
+
"`login-state.json` at 0600, so `auth verify-code` is a separate "
|
|
291
|
+
"command in a separate process. `type` is reported verbatim — a "
|
|
292
|
+
"third-party api_id normally gets `app`, meaning the code lands in "
|
|
293
|
+
"another logged-in Telegram session rather than an SMS."
|
|
294
|
+
),
|
|
295
|
+
mutating=True,
|
|
296
|
+
needs_account=False,
|
|
297
|
+
needs_auth=False,
|
|
298
|
+
rate_class="resolve",
|
|
299
|
+
timeout_s=120,
|
|
300
|
+
columns=("account", "type", "code_hash", "timeout"),
|
|
301
|
+
headers=("Account", "Code type", "Hash", "Timeout"),
|
|
302
|
+
example={
|
|
303
|
+
"phone": "989…89",
|
|
304
|
+
"account": "456789",
|
|
305
|
+
"type": "app",
|
|
306
|
+
"code_hash": "5f2a…",
|
|
307
|
+
"timeout": 60,
|
|
308
|
+
},
|
|
309
|
+
example_args="auth send-code +989123456789",
|
|
310
|
+
covers=(
|
|
311
|
+
"auth.api-credentials",
|
|
312
|
+
"auth.code-type-call",
|
|
313
|
+
"auth.code-type-fragment",
|
|
314
|
+
"auth.code-type-sms-word-phrase",
|
|
315
|
+
"auth.device-identity",
|
|
316
|
+
"auth.future-auth-tokens",
|
|
317
|
+
"auth.login-flood-limits",
|
|
318
|
+
"auth.phone-banned",
|
|
319
|
+
"auth.phone-login-send-code",
|
|
320
|
+
"auth.recaptcha-verification",
|
|
321
|
+
"auth.test-dc-login",
|
|
322
|
+
),
|
|
323
|
+
covers_partial=("auth.code-type-app", "auth.code-type-sms"),
|
|
324
|
+
coverage_note=(
|
|
325
|
+
"The code type is owned here; typing the code is `auth verify-code` "
|
|
326
|
+
"and switching delivery is `auth resend-code`."
|
|
327
|
+
),
|
|
328
|
+
tags=frozenset({"agent-safe"}),
|
|
329
|
+
)
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
# ---------------------------------------------------------------------------
|
|
333
|
+
# auth verify-code
|
|
334
|
+
# ---------------------------------------------------------------------------
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
class VerifyCodeReq(Request):
|
|
338
|
+
code: Annotated[
|
|
339
|
+
str,
|
|
340
|
+
arg(
|
|
341
|
+
0,
|
|
342
|
+
metavar="CODE",
|
|
343
|
+
required=False,
|
|
344
|
+
help="Digits, an SMS word/phrase, or a t.me/login/<code> link. '-' reads stdin.",
|
|
345
|
+
),
|
|
346
|
+
] = ""
|
|
347
|
+
alias: Annotated[str | None, opt("--alias", help="Which pending login to finish.")] = None
|
|
348
|
+
password: Annotated[
|
|
349
|
+
str | None,
|
|
350
|
+
opt(secret=True, envvar="TLGR_2FA_PASSWORD", help="The 2FA cloud password."),
|
|
351
|
+
] = None
|
|
352
|
+
phone: Annotated[str | None, opt("--phone", help="Override the pending login state.")] = None
|
|
353
|
+
code_hash: Annotated[
|
|
354
|
+
str | None, opt("--code-hash", metavar="HASH", help="Override the stored phone_code_hash.")
|
|
355
|
+
] = None
|
|
356
|
+
email_code: Annotated[
|
|
357
|
+
str | None, opt("--email-code", help="Login-email code instead of a phone code.")
|
|
358
|
+
] = None
|
|
359
|
+
google_token: Annotated[
|
|
360
|
+
str | None, opt("--google-token", help="Google id-token for the login email.")
|
|
361
|
+
] = None
|
|
362
|
+
apple_token: Annotated[
|
|
363
|
+
str | None, opt("--apple-token", help="Apple id-token for the login email.")
|
|
364
|
+
] = None
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
def _normalise_code(raw: str) -> str:
|
|
368
|
+
"""Accept a pasted login link; refuse a QR token pasted by mistake.
|
|
369
|
+
|
|
370
|
+
A word or phrase code is passed through untouched — validating it as
|
|
371
|
+
digits is how a client breaks `sentCodeTypeSmsWord` for everyone.
|
|
372
|
+
"""
|
|
373
|
+
import sys
|
|
374
|
+
|
|
375
|
+
value = raw.strip()
|
|
376
|
+
if value == "-":
|
|
377
|
+
value = sys.stdin.read().strip()
|
|
378
|
+
if "token=" in value:
|
|
379
|
+
raise UsageError(
|
|
380
|
+
"that is a QR login token, not a login code. "
|
|
381
|
+
"Approve it with: tlgr account session accept-qr <link>",
|
|
382
|
+
field="code",
|
|
383
|
+
)
|
|
384
|
+
found = _CODE_LINK_RE.search(value)
|
|
385
|
+
return found.group(1) if found else value
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
async def verify_code(ctx: OpContext, req: VerifyCodeReq) -> LoginResult:
|
|
389
|
+
"""Submit the code, and the cloud password when the server asks for one.
|
|
390
|
+
|
|
391
|
+
Three terminal states, all of them reportable: `authorized`,
|
|
392
|
+
`password_required` (raised as exit 4, so an agent can add
|
|
393
|
+
`--password-env` and re-run the identical command), and
|
|
394
|
+
`signup_required`, which is a different command on purpose — tlgr never
|
|
395
|
+
creates an account as a side effect of a failed login.
|
|
396
|
+
"""
|
|
397
|
+
from telethon.tl import types
|
|
398
|
+
from telethon.tl.functions import auth as fn
|
|
399
|
+
|
|
400
|
+
service = _auth.preauth(ctx)
|
|
401
|
+
alias = _login_alias(ctx, service, req.alias)
|
|
402
|
+
phone, code_hash = _pending_login(service, alias, req.phone or "", req.code_hash or "")
|
|
403
|
+
client = await service.client_for(alias)
|
|
404
|
+
|
|
405
|
+
verification: Any = None
|
|
406
|
+
if req.email_code:
|
|
407
|
+
verification = types.EmailVerificationCode(code=req.email_code)
|
|
408
|
+
elif req.google_token:
|
|
409
|
+
verification = types.EmailVerificationGoogle(token=req.google_token)
|
|
410
|
+
elif req.apple_token:
|
|
411
|
+
verification = types.EmailVerificationApple(token=req.apple_token)
|
|
412
|
+
elif not req.code:
|
|
413
|
+
raise UsageError(
|
|
414
|
+
"give the code, or --email-code/--google-token/--apple-token", field="code"
|
|
415
|
+
)
|
|
416
|
+
|
|
417
|
+
try:
|
|
418
|
+
result = await client(
|
|
419
|
+
fn.SignInRequest(
|
|
420
|
+
phone_number=phone,
|
|
421
|
+
phone_code_hash=code_hash,
|
|
422
|
+
phone_code=_normalise_code(req.code) if req.code and not verification else None,
|
|
423
|
+
email_verification=verification,
|
|
424
|
+
)
|
|
425
|
+
)
|
|
426
|
+
except Exception as exc:
|
|
427
|
+
if type(exc).__name__ != "SessionPasswordNeededError":
|
|
428
|
+
raise
|
|
429
|
+
result = await _sign_in_with_password(client, req.password)
|
|
430
|
+
|
|
431
|
+
if type(result).__name__ == "AuthorizationSignUpRequired":
|
|
432
|
+
terms = getattr(result, "terms_of_service", None)
|
|
433
|
+
service.remember(alias, tos_id=getattr(getattr(terms, "id", None), "data", "") or "")
|
|
434
|
+
return LoginResult(
|
|
435
|
+
status="signup_required",
|
|
436
|
+
alias=alias,
|
|
437
|
+
tos_id=getattr(getattr(terms, "id", None), "data", None),
|
|
438
|
+
hint="that number has no account. Register it with: tlgr auth sign-up --first-name …",
|
|
439
|
+
)
|
|
440
|
+
return await _authorized(ctx, service, alias, result)
|
|
441
|
+
|
|
442
|
+
|
|
443
|
+
async def _sign_in_with_password(client: Any, secret: str | None) -> Any:
|
|
444
|
+
"""The SRP half of a login, or exit 4 telling the caller how to supply it."""
|
|
445
|
+
from telethon.tl.functions import auth as fn
|
|
446
|
+
|
|
447
|
+
state = await _auth.get_password(client)
|
|
448
|
+
if secret is None:
|
|
449
|
+
raise AuthPasswordRequiredError(
|
|
450
|
+
"this account has a cloud password. Re-run with --password-env TLGR_2FA_PASSWORD "
|
|
451
|
+
+ (f"(hint: {state.hint}) " if getattr(state, "hint", None) else "")
|
|
452
|
+
+ (
|
|
453
|
+
"or recover it with: tlgr auth recover"
|
|
454
|
+
if getattr(state, "has_recovery", False)
|
|
455
|
+
else "— there is no recovery email on this account"
|
|
456
|
+
)
|
|
457
|
+
)
|
|
458
|
+
return await _auth.with_password(
|
|
459
|
+
client, lambda check: fn.CheckPasswordRequest(password=check), secret, state=state
|
|
460
|
+
)
|
|
461
|
+
|
|
462
|
+
|
|
463
|
+
SPEC_VERIFY_CODE = OperationSpec(
|
|
464
|
+
id="auth.verify-code",
|
|
465
|
+
request=VerifyCodeReq,
|
|
466
|
+
response=LoginResult,
|
|
467
|
+
impl=verify_code,
|
|
468
|
+
summary="Finish a pending login: submit the code and, if asked, the password",
|
|
469
|
+
description=(
|
|
470
|
+
"The password never appears in argv. SRP is recomputed against a "
|
|
471
|
+
"fresh `account.getPassword` when the server answers SRP_ID_INVALID, "
|
|
472
|
+
"and a word or phrase code is passed through unchanged — validating "
|
|
473
|
+
"it as digits is how a client breaks `sentCodeTypeSmsWord`."
|
|
474
|
+
),
|
|
475
|
+
mutating=True,
|
|
476
|
+
needs_account=False,
|
|
477
|
+
needs_auth=False,
|
|
478
|
+
rate_class="resolve",
|
|
479
|
+
columns=("status", "alias", "user_id", "username"),
|
|
480
|
+
example={"status": "authorized", "alias": "work", "user_id": 4242, "username": "me"},
|
|
481
|
+
example_args="auth verify-code 12345",
|
|
482
|
+
covers=(
|
|
483
|
+
"auth.2fa-login",
|
|
484
|
+
"auth.code-type-app",
|
|
485
|
+
"auth.login-code-deep-link",
|
|
486
|
+
"auth.login-email-code",
|
|
487
|
+
"auth.otp-code-input-hygiene",
|
|
488
|
+
),
|
|
489
|
+
covers_partial=("password.setup-required-after-login",),
|
|
490
|
+
coverage_note=(
|
|
491
|
+
"The `setup_password_required` warning is reported here; the password "
|
|
492
|
+
"itself is set with `account password set`."
|
|
493
|
+
),
|
|
494
|
+
tags=frozenset({"agent-safe"}),
|
|
495
|
+
)
|
|
496
|
+
|
|
497
|
+
|
|
498
|
+
# ---------------------------------------------------------------------------
|
|
499
|
+
# auth resend-code
|
|
500
|
+
# ---------------------------------------------------------------------------
|
|
501
|
+
|
|
502
|
+
|
|
503
|
+
class ResendCodeReq(Request):
|
|
504
|
+
alias: Annotated[str | None, opt("--alias", help="Which pending login.")] = None
|
|
505
|
+
phone: Annotated[str | None, opt("--phone", help="Override the pending login state.")] = None
|
|
506
|
+
code_hash: Annotated[
|
|
507
|
+
str | None, opt("--code-hash", metavar="HASH", help="Override the stored hash.")
|
|
508
|
+
] = None
|
|
509
|
+
reason: Annotated[
|
|
510
|
+
str | None, opt("--reason", help="Device-verification failure reason for the server.")
|
|
511
|
+
] = None
|
|
512
|
+
cancel: Annotated[bool, opt("--cancel", help="Cancel the pending code instead.")] = False
|
|
513
|
+
report_missing: Annotated[
|
|
514
|
+
bool, opt("--report-missing", help="Also call auth.reportMissingCode (needs --mnc).")
|
|
515
|
+
] = False
|
|
516
|
+
mnc: Annotated[str | None, opt("--mnc", help="Mobile network code for --report-missing.")] = (
|
|
517
|
+
None
|
|
518
|
+
)
|
|
519
|
+
|
|
520
|
+
|
|
521
|
+
async def resend_code(ctx: OpContext, req: ResendCodeReq) -> SentCode:
|
|
522
|
+
"""Switch the code to the next delivery method, or cancel it.
|
|
523
|
+
|
|
524
|
+
`SEND_CODE_UNAVAILABLE` means every delivery option is exhausted; the
|
|
525
|
+
answer then is QR, not another resend, and the error says so.
|
|
526
|
+
"""
|
|
527
|
+
from telethon.tl.functions import auth as fn
|
|
528
|
+
|
|
529
|
+
service = _auth.preauth(ctx)
|
|
530
|
+
alias = _login_alias(ctx, service, req.alias)
|
|
531
|
+
phone, code_hash = _pending_login(service, alias, req.phone or "", req.code_hash or "")
|
|
532
|
+
client = await service.client_for(alias)
|
|
533
|
+
|
|
534
|
+
if req.cancel:
|
|
535
|
+
await client(fn.CancelCodeRequest(phone_number=phone, phone_code_hash=code_hash))
|
|
536
|
+
service.clear_state(alias)
|
|
537
|
+
return SentCode(phone=_auth.masked(phone), account=alias, cancelled=True)
|
|
538
|
+
|
|
539
|
+
if req.report_missing:
|
|
540
|
+
if not req.mnc:
|
|
541
|
+
raise UsageError(
|
|
542
|
+
"--report-missing needs --mnc: a CLI has no SIM to read the network code from",
|
|
543
|
+
field="mnc",
|
|
544
|
+
)
|
|
545
|
+
await client(
|
|
546
|
+
fn.ReportMissingCodeRequest(phone_number=phone, phone_code_hash=code_hash, mnc=req.mnc)
|
|
547
|
+
)
|
|
548
|
+
|
|
549
|
+
sent = await client(
|
|
550
|
+
fn.ResendCodeRequest(
|
|
551
|
+
phone_number=phone, phone_code_hash=code_hash, reason=req.reason or None
|
|
552
|
+
)
|
|
553
|
+
)
|
|
554
|
+
fields = _auth.sent_code_fields(sent)
|
|
555
|
+
service.remember(alias, phone_code_hash=fields["code_hash"], code_type=fields["type"])
|
|
556
|
+
return SentCode(phone=_auth.masked(phone), account=alias, **fields)
|
|
557
|
+
|
|
558
|
+
|
|
559
|
+
SPEC_RESEND_CODE = OperationSpec(
|
|
560
|
+
id="auth.resend-code",
|
|
561
|
+
request=ResendCodeReq,
|
|
562
|
+
response=SentCode,
|
|
563
|
+
impl=resend_code,
|
|
564
|
+
summary="Resend the pending login code by the next method, or cancel it",
|
|
565
|
+
mutating=True,
|
|
566
|
+
needs_account=False,
|
|
567
|
+
needs_auth=False,
|
|
568
|
+
rate_class="resolve",
|
|
569
|
+
columns=("account", "type", "next_type", "timeout"),
|
|
570
|
+
example={"account": "work", "type": "sms", "next_type": "call", "timeout": 120},
|
|
571
|
+
example_args="auth resend-code --alias work",
|
|
572
|
+
covers=(
|
|
573
|
+
"auth.code-cancel",
|
|
574
|
+
"auth.code-resend",
|
|
575
|
+
"auth.code-type-sms",
|
|
576
|
+
"auth.report-missing-code",
|
|
577
|
+
),
|
|
578
|
+
tags=frozenset({"agent-safe"}),
|
|
579
|
+
)
|
|
580
|
+
|
|
581
|
+
|
|
582
|
+
# ---------------------------------------------------------------------------
|
|
583
|
+
# auth qr
|
|
584
|
+
# ---------------------------------------------------------------------------
|
|
585
|
+
|
|
586
|
+
|
|
587
|
+
class QrReq(Request):
|
|
588
|
+
alias: Annotated[str | None, opt("--alias", help="Account alias to create.")] = None
|
|
589
|
+
password: Annotated[
|
|
590
|
+
str | None, opt(secret=True, envvar="TLGR_2FA_PASSWORD", help="The 2FA cloud password.")
|
|
591
|
+
] = None
|
|
592
|
+
api_id: Annotated[
|
|
593
|
+
int | None, opt("--api-id", metavar="ID", help="api_id for this account.")
|
|
594
|
+
] = None
|
|
595
|
+
api_hash: Annotated[
|
|
596
|
+
str | None, opt(secret=True, envvar="TLGR_API_HASH", help="api_hash for this account.")
|
|
597
|
+
] = None
|
|
598
|
+
url_only: Annotated[
|
|
599
|
+
bool, opt("--url-only", help="Print only the tg://login URL (pipe it into qrencode).")
|
|
600
|
+
] = False
|
|
601
|
+
wait: Annotated[str, opt("--wait", metavar="DURATION", help="Give up after this long.")] = "5m"
|
|
602
|
+
test_dc: Annotated[bool, opt("--test-dc", help="Use the Telegram test DCs.")] = False
|
|
603
|
+
|
|
604
|
+
|
|
605
|
+
async def qr(ctx: OpContext, req: QrReq) -> Any:
|
|
606
|
+
"""Stream QR login tokens until one is approved.
|
|
607
|
+
|
|
608
|
+
QR is the login method that always works for a third-party `api_id`: the
|
|
609
|
+
code path can answer `UPDATE_APP_TO_LOGIN`, which no amount of retrying
|
|
610
|
+
fixes. Each token lives about thirty seconds, so this yields a frame per
|
|
611
|
+
token and re-exports on expiry rather than printing one dead QR.
|
|
612
|
+
|
|
613
|
+
`SESSION_PASSWORD_NEEDED` falls through to the same SRP path as
|
|
614
|
+
`auth verify-code`, which is what makes `--password-env` enough to make a
|
|
615
|
+
QR login unattended too.
|
|
616
|
+
"""
|
|
617
|
+
service = _auth.preauth(ctx)
|
|
618
|
+
alias = validate_alias(req.alias or (getattr(ctx, "account", "") or "").strip() or "qr")
|
|
619
|
+
api_id, api_hash = _credentials(ctx, alias, req.api_id, req.api_hash)
|
|
620
|
+
manager = _auth.accounts(ctx)
|
|
621
|
+
if manager.get_account(alias) is None:
|
|
622
|
+
manager.add_account(alias)
|
|
623
|
+
manager.save_credentials(api_id, api_hash, alias)
|
|
624
|
+
|
|
625
|
+
client = await service.client_for(alias, api_id=api_id, api_hash=api_hash)
|
|
626
|
+
deadline = time.monotonic() + max(10.0, parse_duration(req.wait) or 300)
|
|
627
|
+
while time.monotonic() < deadline:
|
|
628
|
+
login = await client.qr_login(ignored_ids=service.except_ids())
|
|
629
|
+
token = getattr(login, "token", b"")
|
|
630
|
+
yield Page(
|
|
631
|
+
items=[
|
|
632
|
+
QrLogin(
|
|
633
|
+
url=login.url,
|
|
634
|
+
token=_auth.b64(token) if isinstance(token, bytes) else str(token),
|
|
635
|
+
expires=_auth.iso(getattr(login, "expires", None)),
|
|
636
|
+
status="pending",
|
|
637
|
+
alias=alias,
|
|
638
|
+
ascii=None if req.url_only else _render_qr(ctx, login.url),
|
|
639
|
+
)
|
|
640
|
+
],
|
|
641
|
+
# `has_more` is how the walker learns the stream is not over: a
|
|
642
|
+
# page that says False ends it, and a QR login that printed one
|
|
643
|
+
# dead token and stopped would be useless.
|
|
644
|
+
has_more=True,
|
|
645
|
+
)
|
|
646
|
+
try:
|
|
647
|
+
await login.wait(timeout=min(35.0, max(1.0, deadline - time.monotonic())))
|
|
648
|
+
except (TimeoutError, asyncio.TimeoutError):
|
|
649
|
+
continue
|
|
650
|
+
except Exception as exc:
|
|
651
|
+
if type(exc).__name__ != "SessionPasswordNeededError":
|
|
652
|
+
raise
|
|
653
|
+
await _sign_in_with_password(client, req.password)
|
|
654
|
+
done = await _authorized(ctx, service, alias, None)
|
|
655
|
+
yield Page(
|
|
656
|
+
items=[QrLogin(status="authorized", alias=alias, user_id=done.user_id, url=login.url)]
|
|
657
|
+
)
|
|
658
|
+
return
|
|
659
|
+
yield Page(items=[QrLogin(status="expired", alias=alias)])
|
|
660
|
+
|
|
661
|
+
|
|
662
|
+
def _render_qr(ctx: OpContext, url: str) -> str | None:
|
|
663
|
+
"""The QR as unicode half-blocks, when the optional encoder is installed.
|
|
664
|
+
|
|
665
|
+
Degrading to `None` with a warning is deliberate: `tlgr[qr]` is an extra,
|
|
666
|
+
and the URL alone is enough for `--url-only | qrencode`.
|
|
667
|
+
"""
|
|
668
|
+
try:
|
|
669
|
+
import qrcode
|
|
670
|
+
except ImportError:
|
|
671
|
+
ctx.warn("install tlgr[qr] for an inline QR, or pipe --url-only into qrencode")
|
|
672
|
+
return None
|
|
673
|
+
matrix = qrcode.QRCode(border=1)
|
|
674
|
+
matrix.add_data(url)
|
|
675
|
+
matrix.make(fit=True)
|
|
676
|
+
grid = matrix.get_matrix()
|
|
677
|
+
lines = []
|
|
678
|
+
for top in range(0, len(grid), 2):
|
|
679
|
+
upper, lower = grid[top], grid[top + 1] if top + 1 < len(grid) else [False] * len(grid[0])
|
|
680
|
+
lines.append(
|
|
681
|
+
"".join(
|
|
682
|
+
{(True, True): "█", (True, False): "▀", (False, True): "▄", (False, False): " "}[
|
|
683
|
+
(bool(a), bool(b))
|
|
684
|
+
]
|
|
685
|
+
for a, b in zip(upper, lower, strict=False)
|
|
686
|
+
)
|
|
687
|
+
)
|
|
688
|
+
return "\n".join(lines)
|
|
689
|
+
|
|
690
|
+
|
|
691
|
+
SPEC_QR = OperationSpec(
|
|
692
|
+
id="auth.qr",
|
|
693
|
+
request=QrReq,
|
|
694
|
+
response=Page[QrLogin],
|
|
695
|
+
impl=qr,
|
|
696
|
+
summary="Log in by QR code: print the tg://login token and wait for approval",
|
|
697
|
+
description=(
|
|
698
|
+
"Streams a frame per token and re-exports on expiry. Telethon follows "
|
|
699
|
+
"`auth.loginTokenMigrateTo` to the target DC itself, which is the "
|
|
700
|
+
"step a hand-rolled QR login usually forgets."
|
|
701
|
+
),
|
|
702
|
+
mutating=True,
|
|
703
|
+
stream=True,
|
|
704
|
+
needs_account=False,
|
|
705
|
+
needs_auth=False,
|
|
706
|
+
rate_class="resolve",
|
|
707
|
+
timeout_s=600,
|
|
708
|
+
columns=("status", "url", "expires"),
|
|
709
|
+
example={"items": [{"url": "tg://login?token=AQI…", "status": "pending", "alias": "work"}]},
|
|
710
|
+
example_args="auth qr --alias work",
|
|
711
|
+
covers=("auth.multi-account", "auth.qr-login-generate"),
|
|
712
|
+
tags=frozenset({"agent-safe"}),
|
|
713
|
+
)
|
|
714
|
+
|
|
715
|
+
|
|
716
|
+
# ---------------------------------------------------------------------------
|
|
717
|
+
# auth sign-up
|
|
718
|
+
# ---------------------------------------------------------------------------
|
|
719
|
+
|
|
720
|
+
|
|
721
|
+
class SignUpReq(Request):
|
|
722
|
+
first_name: Annotated[str, opt("--first-name", help="Required.")] = ""
|
|
723
|
+
last_name: Annotated[str, opt("--last-name", help="Optional.")] = ""
|
|
724
|
+
alias: Annotated[str | None, opt("--alias", help="Which pending login.")] = None
|
|
725
|
+
accept_tos: Annotated[
|
|
726
|
+
bool, opt("--accept-tos", help="Accept the Terms of Service returned by auth.signIn.")
|
|
727
|
+
] = False
|
|
728
|
+
no_joined_notifications: Annotated[
|
|
729
|
+
bool, opt("--no-joined-notifications", help="Do not tell contacts that you joined.")
|
|
730
|
+
] = False
|
|
731
|
+
|
|
732
|
+
|
|
733
|
+
async def sign_up(ctx: OpContext, req: SignUpReq) -> LoginResult:
|
|
734
|
+
"""Register a new account for a number whose code was already verified.
|
|
735
|
+
|
|
736
|
+
Only reachable after `auth verify-code` answered `signup_required`, and
|
|
737
|
+
only with `--accept-tos`: accepting Terms of Service is a legal act and
|
|
738
|
+
tlgr never performs one implicitly (ARCHITECTURE §1.2 — no *silent*
|
|
739
|
+
account creation).
|
|
740
|
+
"""
|
|
741
|
+
from telethon.tl import types
|
|
742
|
+
from telethon.tl.functions import auth as fn
|
|
743
|
+
from telethon.tl.functions import help as help_fn
|
|
744
|
+
|
|
745
|
+
if not req.first_name:
|
|
746
|
+
raise UsageError("--first-name is required to register an account", field="first_name")
|
|
747
|
+
if not req.accept_tos:
|
|
748
|
+
raise UsageError(
|
|
749
|
+
"registering an account means accepting Telegram's Terms of Service. "
|
|
750
|
+
"Read them with `tlgr auth tos` and pass --accept-tos.",
|
|
751
|
+
field="accept_tos",
|
|
752
|
+
)
|
|
753
|
+
service = _auth.preauth(ctx)
|
|
754
|
+
alias = _login_alias(ctx, service, req.alias)
|
|
755
|
+
phone, code_hash = _pending_login(service, alias, "", "")
|
|
756
|
+
client = await service.client_for(alias)
|
|
757
|
+
|
|
758
|
+
result = await client(
|
|
759
|
+
fn.SignUpRequest(
|
|
760
|
+
phone_number=phone,
|
|
761
|
+
phone_code_hash=code_hash,
|
|
762
|
+
first_name=req.first_name,
|
|
763
|
+
last_name=req.last_name,
|
|
764
|
+
no_joined_notifications=req.no_joined_notifications or None,
|
|
765
|
+
)
|
|
766
|
+
)
|
|
767
|
+
tos_id = str(service.read_state(alias).get("tos_id") or "")
|
|
768
|
+
if tos_id:
|
|
769
|
+
await client(help_fn.AcceptTermsOfServiceRequest(id=types.DataJSON(data=tos_id)))
|
|
770
|
+
finished = await _authorized(ctx, service, alias, result)
|
|
771
|
+
finished.tos_id = tos_id or None
|
|
772
|
+
return finished
|
|
773
|
+
|
|
774
|
+
|
|
775
|
+
SPEC_SIGN_UP = OperationSpec(
|
|
776
|
+
id="auth.sign-up",
|
|
777
|
+
request=SignUpReq,
|
|
778
|
+
response=LoginResult,
|
|
779
|
+
impl=sign_up,
|
|
780
|
+
summary="Register a new account for a phone whose code was already verified",
|
|
781
|
+
description=(
|
|
782
|
+
"A separate command, never a fallback: a login that finds no account "
|
|
783
|
+
"stops with `signup_required` rather than creating one. Third-party "
|
|
784
|
+
"api_ids often get PHONE_NUMBER_APP_SIGNUP_FORBIDDEN, reported as-is."
|
|
785
|
+
),
|
|
786
|
+
mutating=True,
|
|
787
|
+
needs_account=False,
|
|
788
|
+
needs_auth=False,
|
|
789
|
+
rate_class="resolve",
|
|
790
|
+
columns=("status", "alias", "user_id"),
|
|
791
|
+
example={"status": "authorized", "alias": "work", "user_id": 4242},
|
|
792
|
+
example_args="auth sign-up --first-name Ada --accept-tos",
|
|
793
|
+
covers=("auth.sign-up", "auth.signup-notify-contacts"),
|
|
794
|
+
)
|
|
795
|
+
|
|
796
|
+
|
|
797
|
+
# ---------------------------------------------------------------------------
|
|
798
|
+
# auth recover
|
|
799
|
+
# ---------------------------------------------------------------------------
|
|
800
|
+
|
|
801
|
+
|
|
802
|
+
class RecoverReq(Request):
|
|
803
|
+
code: Annotated[
|
|
804
|
+
str | None, opt("--code", help="Recovery code from the email; omit to request one.")
|
|
805
|
+
] = None
|
|
806
|
+
alias: Annotated[str | None, opt("--alias", help="Which pending login (when logged out).")] = (
|
|
807
|
+
None
|
|
808
|
+
)
|
|
809
|
+
check_only: Annotated[
|
|
810
|
+
bool, opt("--check-only", help="Validate the code without consuming it.")
|
|
811
|
+
] = False
|
|
812
|
+
new_password: Annotated[
|
|
813
|
+
str | None,
|
|
814
|
+
opt(
|
|
815
|
+
secret=True,
|
|
816
|
+
envvar="TLGR_2FA_NEW_PASSWORD",
|
|
817
|
+
help="Replacement password; omitted removes the password.",
|
|
818
|
+
),
|
|
819
|
+
] = None
|
|
820
|
+
hint: Annotated[str | None, opt("--hint", help="Hint for the new password.")] = None
|
|
821
|
+
|
|
822
|
+
|
|
823
|
+
async def recover(ctx: OpContext, req: RecoverReq) -> LoginResult:
|
|
824
|
+
"""Recover a forgotten cloud password through the recovery email.
|
|
825
|
+
|
|
826
|
+
Works at a login *and* while logged in, because the RPCs are the same
|
|
827
|
+
three. `PASSWORD_RECOVERY_NA` means there is no recovery email at all —
|
|
828
|
+
the remaining doors are `account password reset` (7 days, keeps the
|
|
829
|
+
account) and `auth reset-account` (immediate, deletes it).
|
|
830
|
+
"""
|
|
831
|
+
from telethon.tl.functions import auth as fn
|
|
832
|
+
|
|
833
|
+
service = _auth.preauth(ctx)
|
|
834
|
+
alias = _login_alias(ctx, service, req.alias)
|
|
835
|
+
pending = service.pending(alias) is not None
|
|
836
|
+
caller = await _caller(ctx, service, alias)
|
|
837
|
+
|
|
838
|
+
if not req.code:
|
|
839
|
+
answer = await caller(fn.RequestPasswordRecoveryRequest())
|
|
840
|
+
return LoginResult(
|
|
841
|
+
status="code_sent",
|
|
842
|
+
alias=alias,
|
|
843
|
+
email_pattern=getattr(answer, "email_pattern", None),
|
|
844
|
+
)
|
|
845
|
+
|
|
846
|
+
if req.check_only:
|
|
847
|
+
await caller(fn.CheckRecoveryPasswordRequest(code=req.code))
|
|
848
|
+
return LoginResult(status="code_valid", alias=alias)
|
|
849
|
+
|
|
850
|
+
settings = None
|
|
851
|
+
if req.new_password:
|
|
852
|
+
state = await _auth.get_password(caller)
|
|
853
|
+
settings = _auth.new_password_settings(
|
|
854
|
+
state, new_password=req.new_password, hint=req.hint or ""
|
|
855
|
+
)
|
|
856
|
+
result = await caller(fn.RecoverPasswordRequest(code=req.code, new_settings=settings))
|
|
857
|
+
if pending and type(result).__name__ == "Authorization":
|
|
858
|
+
return await _authorized(ctx, service, alias, result)
|
|
859
|
+
return LoginResult(status="recovered", alias=alias)
|
|
860
|
+
|
|
861
|
+
|
|
862
|
+
SPEC_RECOVER = OperationSpec(
|
|
863
|
+
id="auth.recover",
|
|
864
|
+
request=RecoverReq,
|
|
865
|
+
response=LoginResult,
|
|
866
|
+
impl=recover,
|
|
867
|
+
summary="Recover a forgotten cloud password through the recovery email",
|
|
868
|
+
mutating=True,
|
|
869
|
+
needs_account=False,
|
|
870
|
+
needs_auth=False,
|
|
871
|
+
rate_class="resolve",
|
|
872
|
+
columns=("status", "email_pattern"),
|
|
873
|
+
example={"status": "code_sent", "email_pattern": "a**@e*****e.com"},
|
|
874
|
+
example_args="auth recover",
|
|
875
|
+
covers=("auth.2fa-login-recover-email", "password.forgot-recover-logged-in"),
|
|
876
|
+
)
|
|
877
|
+
|
|
878
|
+
|
|
879
|
+
# ---------------------------------------------------------------------------
|
|
880
|
+
# auth reset-account
|
|
881
|
+
# ---------------------------------------------------------------------------
|
|
882
|
+
|
|
883
|
+
|
|
884
|
+
class ResetAccountReq(Request):
|
|
885
|
+
alias: Annotated[str | None, opt("--alias", help="Which pending login.")] = None
|
|
886
|
+
reason: Annotated[str, opt("--reason", help="Free-text reason sent to the server.")] = (
|
|
887
|
+
"Forgot password"
|
|
888
|
+
)
|
|
889
|
+
status: Annotated[
|
|
890
|
+
bool, opt("--status", help="Only report a pending reset and its remaining wait.")
|
|
891
|
+
] = False
|
|
892
|
+
confirm_phone: Annotated[
|
|
893
|
+
str | None, opt("--confirm-phone", metavar="PHONE", help="Retype the number — required.")
|
|
894
|
+
] = None
|
|
895
|
+
|
|
896
|
+
|
|
897
|
+
async def reset_account(ctx: OpContext, req: ResetAccountReq) -> AccountDeletion:
|
|
898
|
+
"""Delete an account nobody can log into any more. The last resort.
|
|
899
|
+
|
|
900
|
+
Irreversible, and gated three ways: `--yes`, the phone number typed back,
|
|
901
|
+
and the server's own `2FA_CONFIRM_WAIT_X` countdown, which is persisted
|
|
902
|
+
so a later run reports how much of the wait is left instead of starting
|
|
903
|
+
it again.
|
|
904
|
+
"""
|
|
905
|
+
from telethon.tl.functions import account as fn
|
|
906
|
+
|
|
907
|
+
service = _auth.preauth(ctx)
|
|
908
|
+
alias = _login_alias(ctx, service, req.alias)
|
|
909
|
+
state = service.read_state(alias)
|
|
910
|
+
phone = str(state.get("phone", ""))
|
|
911
|
+
|
|
912
|
+
if req.status:
|
|
913
|
+
wait = int(state.get("reset_wait", 0) or 0)
|
|
914
|
+
until = state.get("reset_until")
|
|
915
|
+
return AccountDeletion(
|
|
916
|
+
status="pending" if wait else "none",
|
|
917
|
+
wait_seconds=wait or None,
|
|
918
|
+
until=str(until) if until else None,
|
|
919
|
+
)
|
|
920
|
+
if not req.confirm_phone or _digits(req.confirm_phone) != _digits(phone):
|
|
921
|
+
raise UsageError(
|
|
922
|
+
"pass --confirm-phone with the account's own number: this deletes the account",
|
|
923
|
+
field="confirm_phone",
|
|
924
|
+
)
|
|
925
|
+
client = await service.client_for(alias)
|
|
926
|
+
try:
|
|
927
|
+
await client(fn.DeleteAccountRequest(reason=req.reason, password=None))
|
|
928
|
+
except Exception as exc:
|
|
929
|
+
remaining = _wait_seconds(str(exc))
|
|
930
|
+
if remaining is None:
|
|
931
|
+
raise
|
|
932
|
+
until = _auth.iso(_auth.now() + timedelta(seconds=remaining))
|
|
933
|
+
service.remember(alias, reset_wait=remaining, reset_until=until or "")
|
|
934
|
+
return AccountDeletion(
|
|
935
|
+
status="wait",
|
|
936
|
+
wait_seconds=remaining,
|
|
937
|
+
until=until,
|
|
938
|
+
confirm_hint=(
|
|
939
|
+
"Telegram sent a confirmation link to the number. Cancel the reset with: "
|
|
940
|
+
"tlgr account phone set --confirm-hash <hash from the tg://confirmphone link>"
|
|
941
|
+
),
|
|
942
|
+
)
|
|
943
|
+
service.clear_state(alias)
|
|
944
|
+
return AccountDeletion(deleted=True, status="deleted")
|
|
945
|
+
|
|
946
|
+
|
|
947
|
+
def _digits(value: str) -> str:
|
|
948
|
+
return "".join(character for character in value if character.isdigit())
|
|
949
|
+
|
|
950
|
+
|
|
951
|
+
def _wait_seconds(message: str) -> int | None:
|
|
952
|
+
found = re.search(r"2FA_CONFIRM_WAIT_(\d+)", message)
|
|
953
|
+
return int(found.group(1)) if found else None
|
|
954
|
+
|
|
955
|
+
|
|
956
|
+
SPEC_RESET_ACCOUNT = OperationSpec(
|
|
957
|
+
id="auth.reset-account",
|
|
958
|
+
request=ResetAccountReq,
|
|
959
|
+
response=AccountDeletion,
|
|
960
|
+
impl=reset_account,
|
|
961
|
+
summary="Delete an account you can no longer log into (last resort)",
|
|
962
|
+
mutating=True,
|
|
963
|
+
destructive=True,
|
|
964
|
+
needs_account=False,
|
|
965
|
+
needs_auth=False,
|
|
966
|
+
rate_class="resolve",
|
|
967
|
+
columns=("status", "wait_seconds", "until"),
|
|
968
|
+
example={"status": "wait", "wait_seconds": 604800, "until": "2026-09-10T09:14:07Z"},
|
|
969
|
+
example_args="auth reset-account --confirm-phone +989123456789",
|
|
970
|
+
covers=("auth.2fa-login-reset-account",),
|
|
971
|
+
)
|
|
972
|
+
|
|
973
|
+
|
|
974
|
+
# ---------------------------------------------------------------------------
|
|
975
|
+
# auth tos
|
|
976
|
+
# ---------------------------------------------------------------------------
|
|
977
|
+
|
|
978
|
+
|
|
979
|
+
class TosReq(Request):
|
|
980
|
+
accept: Annotated[bool, opt("--accept", help="Accept the pending Terms of Service.")] = False
|
|
981
|
+
decline: Annotated[bool, opt("--decline", help="Decline — this deletes the account.")] = False
|
|
982
|
+
delete_account: Annotated[
|
|
983
|
+
bool, opt("--delete-account", help="Acknowledge that declining deletes the account.")
|
|
984
|
+
] = False
|
|
985
|
+
confirm_age: Annotated[
|
|
986
|
+
int | None, opt("--confirm-age", metavar="YEARS", help="Confirm your age when asked.")
|
|
987
|
+
] = None
|
|
988
|
+
|
|
989
|
+
|
|
990
|
+
async def tos(ctx: OpContext, req: TosReq) -> Terms:
|
|
991
|
+
"""Show, accept or decline the Terms of Service.
|
|
992
|
+
|
|
993
|
+
Acceptance is a legal act, so it is never implicit: reading is the
|
|
994
|
+
default and `--accept` is a separate run. Declining calls
|
|
995
|
+
`account.deleteAccount`, which is why it needs `--decline`,
|
|
996
|
+
`--delete-account` and `--yes` together.
|
|
997
|
+
"""
|
|
998
|
+
from telethon.tl import types
|
|
999
|
+
from telethon.tl.functions import account as account_fn
|
|
1000
|
+
from telethon.tl.functions import help as fn
|
|
1001
|
+
|
|
1002
|
+
from tlgr.ops._serialize import message_entities
|
|
1003
|
+
|
|
1004
|
+
client = _auth.client(ctx)
|
|
1005
|
+
update = await client(fn.GetTermsOfServiceUpdateRequest())
|
|
1006
|
+
terms = getattr(update, "terms_of_service", None)
|
|
1007
|
+
available = terms is not None
|
|
1008
|
+
model = Terms(
|
|
1009
|
+
update_available=available,
|
|
1010
|
+
expires=_auth.iso(getattr(update, "expires", None)),
|
|
1011
|
+
id=getattr(getattr(terms, "id", None), "data", None),
|
|
1012
|
+
text=getattr(terms, "text", "") or "",
|
|
1013
|
+
entities=message_entities(terms) if available else [],
|
|
1014
|
+
popup=bool(getattr(terms, "popup", False)),
|
|
1015
|
+
min_age_confirm=getattr(terms, "min_age_confirm", None),
|
|
1016
|
+
)
|
|
1017
|
+
if not (req.accept or req.decline):
|
|
1018
|
+
return model
|
|
1019
|
+
if not available:
|
|
1020
|
+
_auth.already(ctx)
|
|
1021
|
+
return model
|
|
1022
|
+
if req.accept:
|
|
1023
|
+
minimum = model.min_age_confirm
|
|
1024
|
+
if minimum and (req.confirm_age or 0) < minimum:
|
|
1025
|
+
raise UsageError(
|
|
1026
|
+
f"these terms need an age confirmation: pass --confirm-age {minimum} or more",
|
|
1027
|
+
field="confirm_age",
|
|
1028
|
+
)
|
|
1029
|
+
await client(fn.AcceptTermsOfServiceRequest(id=types.DataJSON(data=model.id or "")))
|
|
1030
|
+
model.accepted = True
|
|
1031
|
+
return model
|
|
1032
|
+
if not req.delete_account:
|
|
1033
|
+
raise UsageError(
|
|
1034
|
+
"declining the Terms of Service deletes the account; "
|
|
1035
|
+
"pass --delete-account to acknowledge that",
|
|
1036
|
+
field="delete_account",
|
|
1037
|
+
)
|
|
1038
|
+
await client(account_fn.DeleteAccountRequest(reason="Decline ToS update", password=None))
|
|
1039
|
+
model.declined = True
|
|
1040
|
+
return model
|
|
1041
|
+
|
|
1042
|
+
|
|
1043
|
+
SPEC_TOS = OperationSpec(
|
|
1044
|
+
id="auth.tos",
|
|
1045
|
+
request=TosReq,
|
|
1046
|
+
response=Terms,
|
|
1047
|
+
impl=tos,
|
|
1048
|
+
summary="Show, accept or decline the Terms of Service",
|
|
1049
|
+
aliases=("account.terms",),
|
|
1050
|
+
description=(
|
|
1051
|
+
"The daemon polls `help.getTermsOfServiceUpdate` at the returned "
|
|
1052
|
+
"`expires` and flags a pending update in its status; accepting is "
|
|
1053
|
+
"always an explicit run of this command."
|
|
1054
|
+
),
|
|
1055
|
+
mutating=True,
|
|
1056
|
+
rate_class="read",
|
|
1057
|
+
columns=("update_available", "id", "accepted"),
|
|
1058
|
+
example={"update_available": False, "expires": "2026-09-10T09:14:07Z"},
|
|
1059
|
+
example_args="auth tos",
|
|
1060
|
+
covers=("auth.terms-of-service", "updates.config-terms-of-service"),
|
|
1061
|
+
)
|
|
1062
|
+
|
|
1063
|
+
|
|
1064
|
+
# ---------------------------------------------------------------------------
|
|
1065
|
+
# auth login-email set
|
|
1066
|
+
# ---------------------------------------------------------------------------
|
|
1067
|
+
|
|
1068
|
+
|
|
1069
|
+
class LoginEmailReq(Request):
|
|
1070
|
+
email: Annotated[
|
|
1071
|
+
str | None,
|
|
1072
|
+
arg(
|
|
1073
|
+
0, metavar="EMAIL", required=False, help="Address to attach; omit with --code/--reset."
|
|
1074
|
+
),
|
|
1075
|
+
] = None
|
|
1076
|
+
alias: Annotated[str | None, opt("--alias", help="Which pending login.")] = None
|
|
1077
|
+
code: Annotated[str | None, opt("--code", help="Verification code from the address.")] = None
|
|
1078
|
+
google_token: Annotated[
|
|
1079
|
+
str | None, opt("--google-token", help="Google id-token instead of a code.")
|
|
1080
|
+
] = None
|
|
1081
|
+
apple_token: Annotated[
|
|
1082
|
+
str | None, opt("--apple-token", help="Apple id-token instead of a code.")
|
|
1083
|
+
] = None
|
|
1084
|
+
reset: Annotated[
|
|
1085
|
+
bool, opt("--reset", help="Start auth.resetLoginEmail for an address you cannot read.")
|
|
1086
|
+
] = False
|
|
1087
|
+
|
|
1088
|
+
|
|
1089
|
+
async def login_email_set(ctx: OpContext, req: LoginEmailReq) -> LoginEmail:
|
|
1090
|
+
"""Set, verify or reset the login email the server demands mid-login.
|
|
1091
|
+
|
|
1092
|
+
tlgr cannot run the Google or Apple consent screen — no browser, and no
|
|
1093
|
+
business holding one — so `--google-token`/`--apple-token` only *forward*
|
|
1094
|
+
a token the user obtained themselves, and only when the sent code
|
|
1095
|
+
advertised that it would be accepted.
|
|
1096
|
+
"""
|
|
1097
|
+
from telethon.tl import types
|
|
1098
|
+
from telethon.tl.functions import account as fn
|
|
1099
|
+
from telethon.tl.functions import auth as auth_fn
|
|
1100
|
+
|
|
1101
|
+
service = _auth.preauth(ctx)
|
|
1102
|
+
alias = _login_alias(ctx, service, req.alias)
|
|
1103
|
+
phone, code_hash = _pending_login(service, alias, "", "")
|
|
1104
|
+
client = await service.client_for(alias)
|
|
1105
|
+
purpose = types.EmailVerifyPurposeLoginSetup(phone_number=phone, phone_code_hash=code_hash)
|
|
1106
|
+
|
|
1107
|
+
if req.reset:
|
|
1108
|
+
sent = await client(
|
|
1109
|
+
auth_fn.ResetLoginEmailRequest(phone_number=phone, phone_code_hash=code_hash)
|
|
1110
|
+
)
|
|
1111
|
+
fields = _auth.sent_code_fields(sent)
|
|
1112
|
+
service.remember(alias, phone_code_hash=fields["code_hash"])
|
|
1113
|
+
return LoginEmail(sent_code=True, email_pattern=fields.get("email_pattern"))
|
|
1114
|
+
|
|
1115
|
+
verification: Any = None
|
|
1116
|
+
if req.code:
|
|
1117
|
+
verification = types.EmailVerificationCode(code=req.code)
|
|
1118
|
+
elif req.google_token:
|
|
1119
|
+
verification = types.EmailVerificationGoogle(token=req.google_token)
|
|
1120
|
+
elif req.apple_token:
|
|
1121
|
+
verification = types.EmailVerificationApple(token=req.apple_token)
|
|
1122
|
+
|
|
1123
|
+
if verification is None:
|
|
1124
|
+
if not req.email:
|
|
1125
|
+
raise UsageError("give an email address, or --code to verify one", field="email")
|
|
1126
|
+
sent = await client(fn.SendVerifyEmailCodeRequest(purpose=purpose, email=req.email))
|
|
1127
|
+
return LoginEmail(
|
|
1128
|
+
sent_code=True,
|
|
1129
|
+
email_pattern=getattr(sent, "email_pattern", None),
|
|
1130
|
+
length=getattr(sent, "length", None),
|
|
1131
|
+
)
|
|
1132
|
+
|
|
1133
|
+
verified = await client(fn.VerifyEmailRequest(purpose=purpose, verification=verification))
|
|
1134
|
+
sent_code = getattr(verified, "sent_code", None)
|
|
1135
|
+
if sent_code is not None:
|
|
1136
|
+
# `account.emailVerifiedLogin` carries a fresh code for the login that
|
|
1137
|
+
# is still pending; it replaces the stored hash.
|
|
1138
|
+
service.remember(alias, phone_code_hash=getattr(sent_code, "phone_code_hash", ""))
|
|
1139
|
+
return LoginEmail(verified=True, email_pattern=getattr(verified, "email", None))
|
|
1140
|
+
|
|
1141
|
+
|
|
1142
|
+
SPEC_LOGIN_EMAIL_SET = OperationSpec(
|
|
1143
|
+
id="auth.login-email.set",
|
|
1144
|
+
request=LoginEmailReq,
|
|
1145
|
+
response=LoginEmail,
|
|
1146
|
+
impl=login_email_set,
|
|
1147
|
+
summary="Set, verify or reset the login email the server demands during login",
|
|
1148
|
+
mutating=True,
|
|
1149
|
+
needs_account=False,
|
|
1150
|
+
needs_auth=False,
|
|
1151
|
+
rate_class="resolve",
|
|
1152
|
+
columns=("email_pattern", "verified", "sent_code"),
|
|
1153
|
+
example={"email_pattern": "a**@e*****e.com", "sent_code": True},
|
|
1154
|
+
example_args="auth login-email set ada@example.com",
|
|
1155
|
+
covers=(
|
|
1156
|
+
"auth.login-email-change",
|
|
1157
|
+
"auth.login-email-google-apple-signin",
|
|
1158
|
+
"auth.login-email-reset",
|
|
1159
|
+
"auth.login-email-setup-required",
|
|
1160
|
+
),
|
|
1161
|
+
)
|
|
1162
|
+
|
|
1163
|
+
|
|
1164
|
+
# ---------------------------------------------------------------------------
|
|
1165
|
+
# auth code list
|
|
1166
|
+
# ---------------------------------------------------------------------------
|
|
1167
|
+
|
|
1168
|
+
|
|
1169
|
+
class CodeListReq(Request):
|
|
1170
|
+
wait: Annotated[bool, opt("--wait", help="Block until a fresh code arrives.")] = False
|
|
1171
|
+
wait_timeout: Annotated[
|
|
1172
|
+
str, opt("--wait-timeout", metavar="DURATION", help="Give up waiting after this long.")
|
|
1173
|
+
] = "2m"
|
|
1174
|
+
scan: Annotated[
|
|
1175
|
+
int, opt("--limit", metavar="N", help="How many service messages to scan.", ge=1, le=100)
|
|
1176
|
+
] = 10
|
|
1177
|
+
invalidate: Annotated[
|
|
1178
|
+
tuple[str, ...],
|
|
1179
|
+
opt("--invalidate", metavar="CODE", help="Invalidate these codes (repeatable)."),
|
|
1180
|
+
] = ()
|
|
1181
|
+
|
|
1182
|
+
|
|
1183
|
+
async def code_list(ctx: OpContext, req: CodeListReq) -> LoginCodes:
|
|
1184
|
+
"""Read the login codes Telegram delivered into this account's 777000 chat.
|
|
1185
|
+
|
|
1186
|
+
This is what makes scripted multi-account onboarding possible: account B
|
|
1187
|
+
reads the code Telegram sent for account A's new login. `--invalidate`
|
|
1188
|
+
burns a code that has leaked — the same hardening any client that reads
|
|
1189
|
+
this chat should do.
|
|
1190
|
+
"""
|
|
1191
|
+
from telethon.tl.functions import account as fn
|
|
1192
|
+
|
|
1193
|
+
client = _auth.client(ctx)
|
|
1194
|
+
if req.invalidate:
|
|
1195
|
+
await client(fn.InvalidateSignInCodesRequest(codes=list(req.invalidate)))
|
|
1196
|
+
|
|
1197
|
+
deadline = time.monotonic() + ((parse_duration(req.wait_timeout) or 120) if req.wait else 0)
|
|
1198
|
+
seen: list[str] = []
|
|
1199
|
+
texts: list[str] = []
|
|
1200
|
+
while True:
|
|
1201
|
+
texts = []
|
|
1202
|
+
seen = []
|
|
1203
|
+
for message in await client.get_messages(SERVICE_CHAT, limit=req.scan):
|
|
1204
|
+
body = getattr(message, "message", "") or ""
|
|
1205
|
+
if not body:
|
|
1206
|
+
continue
|
|
1207
|
+
texts.append(body)
|
|
1208
|
+
seen.extend(_CODE_RE.findall(body))
|
|
1209
|
+
if seen or not req.wait or time.monotonic() >= deadline:
|
|
1210
|
+
break
|
|
1211
|
+
await asyncio.sleep(2.0)
|
|
1212
|
+
return LoginCodes(codes=seen, messages=texts, invalidated=list(req.invalidate))
|
|
1213
|
+
|
|
1214
|
+
|
|
1215
|
+
SPEC_CODE_LIST = OperationSpec(
|
|
1216
|
+
id="auth.code.list",
|
|
1217
|
+
request=CodeListReq,
|
|
1218
|
+
response=LoginCodes,
|
|
1219
|
+
impl=code_list,
|
|
1220
|
+
summary="Read login codes Telegram delivered to this session, and burn leaked ones",
|
|
1221
|
+
mutating=False,
|
|
1222
|
+
rate_class="read",
|
|
1223
|
+
timeout_s=300,
|
|
1224
|
+
columns=("codes",),
|
|
1225
|
+
example={"codes": ["12345"], "messages": ["Login code: 12345. Do not give this code…"]},
|
|
1226
|
+
example_args="auth code list",
|
|
1227
|
+
covers=("auth.login-codes-from-service-chat", "privacy.invalidate-sign-in-codes"),
|
|
1228
|
+
tags=frozenset({"agent-safe", "mutating-checked"}),
|
|
1229
|
+
)
|
|
1230
|
+
|
|
1231
|
+
|
|
1232
|
+
# ---------------------------------------------------------------------------
|
|
1233
|
+
# auth autologin-url get
|
|
1234
|
+
# ---------------------------------------------------------------------------
|
|
1235
|
+
|
|
1236
|
+
|
|
1237
|
+
class AutologinReq(Request):
|
|
1238
|
+
url: Annotated[str, arg(0, metavar="URL", help="A telegram.org URL to sign into.")]
|
|
1239
|
+
|
|
1240
|
+
|
|
1241
|
+
async def autologin_url_get(ctx: OpContext, req: AutologinReq) -> AutologinUrl:
|
|
1242
|
+
"""Append `autologin_token` to a telegram.org URL so it opens signed in.
|
|
1243
|
+
|
|
1244
|
+
Refused outside `help.getAppConfig.autologin_domains`: the token is a
|
|
1245
|
+
bearer credential and handing it to an arbitrary host is handing over the
|
|
1246
|
+
account's web session.
|
|
1247
|
+
"""
|
|
1248
|
+
from urllib.parse import urlparse, urlunparse
|
|
1249
|
+
|
|
1250
|
+
client = _auth.client(ctx)
|
|
1251
|
+
config = await _auth.app_config(client)
|
|
1252
|
+
domains = [str(d) for d in (config.get("autologin_domains") or [])]
|
|
1253
|
+
parsed = urlparse(req.url if "://" in req.url else f"https://{req.url}")
|
|
1254
|
+
host = (parsed.hostname or "").lower()
|
|
1255
|
+
allowed = any(host == d.lower() or host.endswith(f".{d.lower()}") for d in domains)
|
|
1256
|
+
if not allowed:
|
|
1257
|
+
raise UsageError(
|
|
1258
|
+
f"{host or req.url!r} is not in autologin_domains ({', '.join(domains) or 'none'}); "
|
|
1259
|
+
"the autologin token is a bearer credential and is never sent elsewhere",
|
|
1260
|
+
field="url",
|
|
1261
|
+
)
|
|
1262
|
+
token = str(config.get("autologin_token") or "")
|
|
1263
|
+
if not token:
|
|
1264
|
+
raise AuthenticationError("the server issued no autologin_token for this account")
|
|
1265
|
+
query = (
|
|
1266
|
+
f"{parsed.query}&autologin_token={token}" if parsed.query else f"autologin_token={token}"
|
|
1267
|
+
)
|
|
1268
|
+
return AutologinUrl(url=urlunparse(parsed._replace(query=query)), domain_allowed=True)
|
|
1269
|
+
|
|
1270
|
+
|
|
1271
|
+
SPEC_AUTOLOGIN_URL_GET = OperationSpec(
|
|
1272
|
+
id="auth.autologin-url.get",
|
|
1273
|
+
request=AutologinReq,
|
|
1274
|
+
response=AutologinUrl,
|
|
1275
|
+
impl=autologin_url_get,
|
|
1276
|
+
summary="Append the autologin token to a telegram.org URL",
|
|
1277
|
+
rate_class="read",
|
|
1278
|
+
columns=("url", "domain_allowed"),
|
|
1279
|
+
example={"url": "https://telegram.org/faq?autologin_token=…", "domain_allowed": True},
|
|
1280
|
+
example_args="auth autologin-url get https://telegram.org/faq",
|
|
1281
|
+
covers=("auth.autologin-token",),
|
|
1282
|
+
)
|