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/account.py
ADDED
|
@@ -0,0 +1,2604 @@
|
|
|
1
|
+
"""The `account` group: the local alias registry, and everything Telegram's
|
|
2
|
+
Settings ▸ Privacy and Security screen can do.
|
|
3
|
+
|
|
4
|
+
Five sub-nouns, one theme — *who can act as me*:
|
|
5
|
+
|
|
6
|
+
* `account session *` is the Devices list: every authorization, what each may
|
|
7
|
+
do, and how to end one.
|
|
8
|
+
* `account password *` is 2-step verification, including the SRP prompt every
|
|
9
|
+
other sensitive operation in tlgr reuses.
|
|
10
|
+
* `account website *` is Telegram Login on the web, which is a **different**
|
|
11
|
+
list from Devices and is the one people forget.
|
|
12
|
+
* `account passkey *` is read-only on purpose: the server only accepts the
|
|
13
|
+
RP id `telegram.org`, so tlgr can audit passkeys but can never mint one.
|
|
14
|
+
* `account ttl/phone/email/delete` are the account-level switches that are
|
|
15
|
+
easy to get wrong once and impossible to undo.
|
|
16
|
+
|
|
17
|
+
Two rules run through all of it. **The daemon owns the session file** — v1's
|
|
18
|
+
`account add` opened it from the CLI while the daemon might already hold it,
|
|
19
|
+
which is the two-writer situation that earns `AUTH_KEY_DUPLICATED` and gets
|
|
20
|
+
the authorization revoked. And **a secret never reaches argv**: every
|
|
21
|
+
password, token and api_hash arrives through `--x-env`, `--x-stdin` or
|
|
22
|
+
`--x-file`.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
import contextlib
|
|
28
|
+
import shutil
|
|
29
|
+
from datetime import timedelta
|
|
30
|
+
from typing import Annotated, Any
|
|
31
|
+
|
|
32
|
+
from tlgr.core.errors import (
|
|
33
|
+
AccountNotFoundError,
|
|
34
|
+
AuthenticationError,
|
|
35
|
+
TlgrError,
|
|
36
|
+
UsageError,
|
|
37
|
+
)
|
|
38
|
+
from tlgr.core.pagination import PageKind
|
|
39
|
+
from tlgr.core.paths import secure_session_files, validate_alias, write_private
|
|
40
|
+
from tlgr.core.timefmt import parse_duration
|
|
41
|
+
from tlgr.models.auth import (
|
|
42
|
+
AccountDeletion,
|
|
43
|
+
AccountRecord,
|
|
44
|
+
AccountState,
|
|
45
|
+
AccountTtl,
|
|
46
|
+
DeviceLock,
|
|
47
|
+
Passkey,
|
|
48
|
+
PasswordReset,
|
|
49
|
+
PasswordState,
|
|
50
|
+
PhoneChange,
|
|
51
|
+
RecoveryEmail,
|
|
52
|
+
Session,
|
|
53
|
+
SessionChange,
|
|
54
|
+
SessionTermination,
|
|
55
|
+
SmsJobs,
|
|
56
|
+
Suggestion,
|
|
57
|
+
SupportInfo,
|
|
58
|
+
TempPassword,
|
|
59
|
+
WebSession,
|
|
60
|
+
WebSessionRevocation,
|
|
61
|
+
)
|
|
62
|
+
from tlgr.models.base import Request
|
|
63
|
+
from tlgr.models.page import Page
|
|
64
|
+
from tlgr.models.peer import PeerRef
|
|
65
|
+
from tlgr.ops import _auth, _send
|
|
66
|
+
from tlgr.ops._params import arg, choice, opt
|
|
67
|
+
from tlgr.ops._spec import OpContext, OperationSpec, Surface
|
|
68
|
+
|
|
69
|
+
__all__ = [
|
|
70
|
+
"SPEC_ADD",
|
|
71
|
+
"SPEC_CHECK",
|
|
72
|
+
"SPEC_DELETE",
|
|
73
|
+
"SPEC_DEVICE_LOCKED_SET",
|
|
74
|
+
"SPEC_EMAIL_SET",
|
|
75
|
+
"SPEC_EXPORT",
|
|
76
|
+
"SPEC_IMPORT",
|
|
77
|
+
"SPEC_INFO",
|
|
78
|
+
"SPEC_LIST",
|
|
79
|
+
"SPEC_LOGOUT",
|
|
80
|
+
"SPEC_PASSKEY_DELETE",
|
|
81
|
+
"SPEC_PASSKEY_LIST",
|
|
82
|
+
"SPEC_PASSWORD_CHANGE",
|
|
83
|
+
"SPEC_PASSWORD_GET",
|
|
84
|
+
"SPEC_PASSWORD_REMOVE",
|
|
85
|
+
"SPEC_PASSWORD_RESET",
|
|
86
|
+
"SPEC_PASSWORD_SET",
|
|
87
|
+
"SPEC_PASSWORD_TEMP",
|
|
88
|
+
"SPEC_PHONE_SET",
|
|
89
|
+
"SPEC_REMOVE",
|
|
90
|
+
"SPEC_RENAME",
|
|
91
|
+
"SPEC_SESSION_ACCEPT_QR",
|
|
92
|
+
"SPEC_SESSION_CONFIRM",
|
|
93
|
+
"SPEC_SESSION_LIST",
|
|
94
|
+
"SPEC_SESSION_SET",
|
|
95
|
+
"SPEC_SESSION_TERMINATE",
|
|
96
|
+
"SPEC_SMSJOBS_SET",
|
|
97
|
+
"SPEC_SUGGESTION_LIST",
|
|
98
|
+
"SPEC_SUPPORT_GET",
|
|
99
|
+
"SPEC_SWITCH",
|
|
100
|
+
"SPEC_SYNC",
|
|
101
|
+
"SPEC_TTL_GET",
|
|
102
|
+
"SPEC_TTL_SET",
|
|
103
|
+
"SPEC_WEBSITE_LIST",
|
|
104
|
+
"SPEC_WEBSITE_REVOKE",
|
|
105
|
+
]
|
|
106
|
+
|
|
107
|
+
_PASSWORD = opt(
|
|
108
|
+
secret=True, envvar="TLGR_2FA_PASSWORD", help="The 2FA cloud password (never in argv)."
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
# ---------------------------------------------------------------------------
|
|
113
|
+
# account list / switch / rename — local, and they work with no daemon
|
|
114
|
+
# ---------------------------------------------------------------------------
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
class ListReq(Request):
|
|
118
|
+
pass
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
async def list_accounts(ctx: OpContext, req: ListReq) -> Page[AccountRecord]:
|
|
122
|
+
"""Every configured account, with the health the daemon last recorded.
|
|
123
|
+
|
|
124
|
+
Health is read from `accounts.json` rather than from a live daemon, which
|
|
125
|
+
is the point: you ask "is my account still working?" exactly when the
|
|
126
|
+
daemon is *not* running, and v1 answered "unknown" then.
|
|
127
|
+
"""
|
|
128
|
+
manager = _auth.accounts(ctx)
|
|
129
|
+
active = manager.get_active()
|
|
130
|
+
items = [
|
|
131
|
+
AccountRecord(
|
|
132
|
+
alias=info.alias,
|
|
133
|
+
name=info.display_name(),
|
|
134
|
+
user_id=info.user_id,
|
|
135
|
+
username=info.username,
|
|
136
|
+
phone=info.phone,
|
|
137
|
+
active=info.alias == active,
|
|
138
|
+
connected=info.health.state == "online",
|
|
139
|
+
state=info.health.state,
|
|
140
|
+
created_at=info.created_at,
|
|
141
|
+
)
|
|
142
|
+
for info in manager.list_accounts()
|
|
143
|
+
]
|
|
144
|
+
return Page(items=items, has_more=False, total=len(items))
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
SPEC_LIST = OperationSpec(
|
|
148
|
+
id="account.list",
|
|
149
|
+
request=ListReq,
|
|
150
|
+
response=Page[AccountRecord],
|
|
151
|
+
impl=list_accounts,
|
|
152
|
+
summary="List the accounts configured in this installation",
|
|
153
|
+
aliases=("accounts",),
|
|
154
|
+
legacy_paths=("account list",),
|
|
155
|
+
needs_account=False,
|
|
156
|
+
needs_auth=False,
|
|
157
|
+
surface=Surface.LOCAL,
|
|
158
|
+
idempotent=True,
|
|
159
|
+
rate_class="local",
|
|
160
|
+
paginated=PageKind.LOCAL,
|
|
161
|
+
columns=("alias", "user_id", "name", "phone", "active", "state"),
|
|
162
|
+
headers=("Alias", "User ID", "Name", "Phone", "Active", "State"),
|
|
163
|
+
example={
|
|
164
|
+
"items": [
|
|
165
|
+
{
|
|
166
|
+
"alias": "work",
|
|
167
|
+
"name": "@me",
|
|
168
|
+
"user_id": 4242,
|
|
169
|
+
"phone": "+989123456789",
|
|
170
|
+
"active": True,
|
|
171
|
+
"state": "online",
|
|
172
|
+
}
|
|
173
|
+
],
|
|
174
|
+
"has_more": False,
|
|
175
|
+
},
|
|
176
|
+
example_args="account list",
|
|
177
|
+
covers=("auth.multi-account",),
|
|
178
|
+
coverage_note="Adding is `account add`, switching is `account switch`.",
|
|
179
|
+
tags=frozenset({"agent-safe", "infrastructure"}),
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
class SwitchReq(Request):
|
|
184
|
+
alias: Annotated[str, arg(0, metavar="ACCOUNT", help="The alias to make default.")]
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
async def switch(ctx: OpContext, req: SwitchReq) -> AccountState:
|
|
188
|
+
"""Make another configured account the default for later commands."""
|
|
189
|
+
manager = _auth.accounts(ctx)
|
|
190
|
+
alias = validate_alias(req.alias)
|
|
191
|
+
if manager.get_account(alias) is None:
|
|
192
|
+
raise AccountNotFoundError(f"no account named {alias!r}. Run: tlgr account list")
|
|
193
|
+
if manager.get_active() == alias:
|
|
194
|
+
_auth.already(ctx)
|
|
195
|
+
return AccountState(ok=True, account=alias, alias=alias, already=True)
|
|
196
|
+
manager.set_active(alias)
|
|
197
|
+
return AccountState(ok=True, account=alias, alias=alias)
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
SPEC_SWITCH = OperationSpec(
|
|
201
|
+
id="account.switch",
|
|
202
|
+
request=SwitchReq,
|
|
203
|
+
response=AccountState,
|
|
204
|
+
impl=switch,
|
|
205
|
+
summary="Make another configured account the default for later commands",
|
|
206
|
+
legacy_paths=("account switch",),
|
|
207
|
+
mutating=True,
|
|
208
|
+
idempotent=True,
|
|
209
|
+
needs_account=False,
|
|
210
|
+
needs_auth=False,
|
|
211
|
+
surface=Surface.LOCAL,
|
|
212
|
+
rate_class="local",
|
|
213
|
+
columns=("ok", "account"),
|
|
214
|
+
example={"ok": True, "account": "work"},
|
|
215
|
+
example_args="account switch work",
|
|
216
|
+
covers=(),
|
|
217
|
+
tags=frozenset({"agent-safe", "infrastructure"}),
|
|
218
|
+
)
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
class RenameReq(Request):
|
|
222
|
+
alias: Annotated[str, arg(0, metavar="ACCOUNT", help="The alias to rename.")]
|
|
223
|
+
name: Annotated[str, arg(1, metavar="NAME", help="Its new local label.")]
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
async def rename(ctx: OpContext, req: RenameReq) -> AccountState:
|
|
227
|
+
"""Rename an account's local label. Nothing server-side changes."""
|
|
228
|
+
manager = _auth.accounts(ctx)
|
|
229
|
+
old, new = validate_alias(req.alias), validate_alias(req.name)
|
|
230
|
+
if not manager.rename_account(old, new):
|
|
231
|
+
raise AccountNotFoundError(f"no account named {old!r}. Run: tlgr account list")
|
|
232
|
+
return AccountState(ok=True, account=new, alias=new, old=old, new=new)
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
SPEC_RENAME = OperationSpec(
|
|
236
|
+
id="account.rename",
|
|
237
|
+
request=RenameReq,
|
|
238
|
+
response=AccountState,
|
|
239
|
+
impl=rename,
|
|
240
|
+
summary="Rename a configured account (its local label)",
|
|
241
|
+
legacy_paths=("account rename",),
|
|
242
|
+
mutating=True,
|
|
243
|
+
needs_account=False,
|
|
244
|
+
needs_auth=False,
|
|
245
|
+
surface=Surface.LOCAL,
|
|
246
|
+
rate_class="local",
|
|
247
|
+
columns=("ok", "old", "new"),
|
|
248
|
+
example={"ok": True, "account": "personal", "old": "work", "new": "personal"},
|
|
249
|
+
example_args="account rename work personal",
|
|
250
|
+
covers=(),
|
|
251
|
+
tags=frozenset({"agent-safe", "infrastructure"}),
|
|
252
|
+
)
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
# ---------------------------------------------------------------------------
|
|
256
|
+
# account add / import / export / remove / logout / check
|
|
257
|
+
# ---------------------------------------------------------------------------
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
class AddReq(Request):
|
|
261
|
+
phone: Annotated[
|
|
262
|
+
str, arg(0, metavar="PHONE", required=False, help="Omit with --bot or --qr.")
|
|
263
|
+
] = ""
|
|
264
|
+
alias: Annotated[str | None, opt("--alias", help="Local name for the account.")] = None
|
|
265
|
+
bot: Annotated[bool, opt("--bot", help="Log in as a bot with a token instead.")] = False
|
|
266
|
+
token: Annotated[
|
|
267
|
+
str | None, opt(secret=True, envvar="TLGR_BOT_TOKEN", help="The bot token.")
|
|
268
|
+
] = None
|
|
269
|
+
use_qr: Annotated[bool, opt("--qr", help="Use QR login instead of a phone code.")] = False
|
|
270
|
+
api_id: Annotated[
|
|
271
|
+
int | None, opt("--api-id", metavar="ID", help="api_id for this account.")
|
|
272
|
+
] = None
|
|
273
|
+
api_hash: Annotated[
|
|
274
|
+
str | None, opt(secret=True, envvar="TLGR_API_HASH", help="api_hash for this account.")
|
|
275
|
+
] = None
|
|
276
|
+
test_dc: Annotated[bool, opt("--test-dc", help="Use the Telegram test DCs.")] = False
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
async def add(ctx: OpContext, req: AddReq) -> AccountState:
|
|
280
|
+
"""Add an account: a bot in one step, a user in two.
|
|
281
|
+
|
|
282
|
+
A bot token is a complete credential, so `--bot` finishes here. A phone
|
|
283
|
+
login cannot: somebody has to read a code. Rather than hold a process
|
|
284
|
+
open on `input()` the way v1 did, this starts the login and returns the
|
|
285
|
+
exact next command — which is what makes the same path work for a human
|
|
286
|
+
and for an agent.
|
|
287
|
+
|
|
288
|
+
The login runs on the daemon's pre-auth client, never on a second
|
|
289
|
+
`TelegramClient` opened over the session file.
|
|
290
|
+
"""
|
|
291
|
+
from telethon.tl.functions import auth as fn
|
|
292
|
+
|
|
293
|
+
from tlgr.ops.auth import SendCodeReq, _credentials, _default_alias, send_code
|
|
294
|
+
|
|
295
|
+
if req.use_qr:
|
|
296
|
+
raise UsageError(
|
|
297
|
+
"QR login streams tokens until one is approved, so it is its own command. "
|
|
298
|
+
"Run: tlgr auth qr --alias <name>",
|
|
299
|
+
field="qr",
|
|
300
|
+
)
|
|
301
|
+
service = _auth.preauth(ctx)
|
|
302
|
+
manager = _auth.accounts(ctx)
|
|
303
|
+
|
|
304
|
+
if req.bot or req.token:
|
|
305
|
+
if not req.token:
|
|
306
|
+
raise UsageError(
|
|
307
|
+
"--bot needs a token: --token-env TLGR_BOT_TOKEN, --token-stdin or --token-file",
|
|
308
|
+
field="token",
|
|
309
|
+
)
|
|
310
|
+
alias = validate_alias(req.alias or f"bot{req.token.split(':', 1)[0]}")
|
|
311
|
+
api_id, api_hash = _credentials(ctx, alias, req.api_id, req.api_hash)
|
|
312
|
+
if manager.get_account(alias) is None:
|
|
313
|
+
manager.add_account(alias)
|
|
314
|
+
manager.save_credentials(api_id, api_hash, alias)
|
|
315
|
+
client = await service.client_for(alias, api_id=api_id, api_hash=api_hash)
|
|
316
|
+
await client(
|
|
317
|
+
fn.ImportBotAuthorizationRequest(
|
|
318
|
+
flags=0, api_id=api_id, api_hash=api_hash, bot_auth_token=req.token
|
|
319
|
+
)
|
|
320
|
+
)
|
|
321
|
+
finished = await service.finish(alias)
|
|
322
|
+
_mark_kind(manager, alias, "bot")
|
|
323
|
+
return AccountState(
|
|
324
|
+
alias=alias,
|
|
325
|
+
account=alias,
|
|
326
|
+
ok=True,
|
|
327
|
+
authorized=True,
|
|
328
|
+
kind="bot",
|
|
329
|
+
user_id=finished.get("user_id"),
|
|
330
|
+
username=finished.get("username"),
|
|
331
|
+
test_dc=req.test_dc,
|
|
332
|
+
)
|
|
333
|
+
|
|
334
|
+
if not req.phone:
|
|
335
|
+
raise UsageError("give a phone number, or --bot with a token", field="phone")
|
|
336
|
+
sent = await send_code(
|
|
337
|
+
ctx,
|
|
338
|
+
SendCodeReq(
|
|
339
|
+
phone=req.phone,
|
|
340
|
+
alias=req.alias or _default_alias(req.phone),
|
|
341
|
+
api_id=req.api_id,
|
|
342
|
+
api_hash=req.api_hash,
|
|
343
|
+
test_dc=req.test_dc,
|
|
344
|
+
),
|
|
345
|
+
)
|
|
346
|
+
alias = sent.account
|
|
347
|
+
if sent.already:
|
|
348
|
+
return AccountState(alias=alias, account=alias, ok=True, authorized=True, kind="user")
|
|
349
|
+
return AccountState(
|
|
350
|
+
alias=alias,
|
|
351
|
+
account=alias,
|
|
352
|
+
ok=True,
|
|
353
|
+
phone=sent.phone,
|
|
354
|
+
kind="user",
|
|
355
|
+
test_dc=req.test_dc,
|
|
356
|
+
hint=(
|
|
357
|
+
f"a {sent.type} code was sent. Finish with: "
|
|
358
|
+
f"tlgr auth verify-code <code> --alias {alias}"
|
|
359
|
+
),
|
|
360
|
+
)
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
def _mark_kind(manager: Any, alias: str, kind: str) -> None:
|
|
364
|
+
"""Record `kind=bot` so user-only commands can fail fast rather than at the RPC."""
|
|
365
|
+
path = manager.paths.account_dir(alias) / "kind"
|
|
366
|
+
write_private(path, kind)
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
def _kind_of(manager: Any, alias: str) -> str:
|
|
370
|
+
path = manager.paths.account_dir(alias) / "kind"
|
|
371
|
+
with contextlib.suppress(OSError):
|
|
372
|
+
return path.read_text(encoding="utf-8").strip() or "user"
|
|
373
|
+
return "user"
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
SPEC_ADD = OperationSpec(
|
|
377
|
+
id="account.add",
|
|
378
|
+
request=AddReq,
|
|
379
|
+
response=AccountState,
|
|
380
|
+
impl=add,
|
|
381
|
+
summary="Add and authenticate an account (phone, bot token, or QR)",
|
|
382
|
+
description=(
|
|
383
|
+
"A bot token finishes in one call. A phone login starts here and "
|
|
384
|
+
"finishes with `auth verify-code`, because somebody has to read a "
|
|
385
|
+
"code and a daemon cannot prompt. `kind=bot` is recorded so that "
|
|
386
|
+
"user-only commands fail fast."
|
|
387
|
+
),
|
|
388
|
+
aliases=("login",),
|
|
389
|
+
legacy_paths=("account add", "login"),
|
|
390
|
+
mutating=True,
|
|
391
|
+
needs_account=False,
|
|
392
|
+
needs_auth=False,
|
|
393
|
+
rate_class="resolve",
|
|
394
|
+
columns=("alias", "user_id", "username", "kind"),
|
|
395
|
+
example={"alias": "work", "user_id": 4242, "username": "me", "kind": "user", "ok": True},
|
|
396
|
+
example_args="account add +989123456789",
|
|
397
|
+
covers=("auth.bot-token-login", "bots.login-as-bot"),
|
|
398
|
+
covers_partial=(
|
|
399
|
+
"auth.api-credentials",
|
|
400
|
+
"auth.device-identity",
|
|
401
|
+
"auth.multi-account",
|
|
402
|
+
"auth.qr-login-generate",
|
|
403
|
+
"auth.test-dc-login",
|
|
404
|
+
),
|
|
405
|
+
coverage_note=(
|
|
406
|
+
"The wrapper; the owning commands are `auth send-code` for codes and `auth qr` for QR."
|
|
407
|
+
),
|
|
408
|
+
)
|
|
409
|
+
|
|
410
|
+
|
|
411
|
+
class ImportReq(Request):
|
|
412
|
+
source: Annotated[
|
|
413
|
+
str,
|
|
414
|
+
arg(0, metavar="SOURCE", kind="path", help="A .session file, or '-' for a StringSession."),
|
|
415
|
+
]
|
|
416
|
+
alias: Annotated[str | None, opt("--alias", help="Local name for the account.")] = None
|
|
417
|
+
string: Annotated[bool, opt("--string", help="Treat the input as a StringSession.")] = False
|
|
418
|
+
api_id: Annotated[
|
|
419
|
+
int | None, opt("--api-id", metavar="ID", help="api_id the session was made with.")
|
|
420
|
+
] = None
|
|
421
|
+
api_hash: Annotated[
|
|
422
|
+
str | None, opt(secret=True, envvar="TLGR_API_HASH", help="The matching api_hash.")
|
|
423
|
+
] = None
|
|
424
|
+
|
|
425
|
+
|
|
426
|
+
async def import_session(ctx: OpContext, req: ImportReq) -> AccountState:
|
|
427
|
+
"""Import an existing Telethon session as a tlgr account.
|
|
428
|
+
|
|
429
|
+
Stop the source client first. One auth key used by two live connections
|
|
430
|
+
is `AUTH_KEY_DUPLICATED`, and Telegram's response to that is to revoke
|
|
431
|
+
the key — so a careless import kills the session it was importing.
|
|
432
|
+
"""
|
|
433
|
+
import sys
|
|
434
|
+
|
|
435
|
+
from telethon.sessions import SQLiteSession, StringSession
|
|
436
|
+
|
|
437
|
+
from tlgr.ops.auth import _credentials
|
|
438
|
+
|
|
439
|
+
manager = _auth.accounts(ctx)
|
|
440
|
+
alias = validate_alias(req.alias or "imported")
|
|
441
|
+
if manager.get_account(alias) is not None:
|
|
442
|
+
raise TlgrError(f"account {alias!r} already exists; pass --alias for a different name")
|
|
443
|
+
api_id, api_hash = _credentials(ctx, alias, req.api_id, req.api_hash)
|
|
444
|
+
|
|
445
|
+
manager.add_account(alias)
|
|
446
|
+
manager.save_credentials(api_id, api_hash, alias)
|
|
447
|
+
destination = manager.paths.session_file(alias)
|
|
448
|
+
try:
|
|
449
|
+
if req.string or req.source == "-":
|
|
450
|
+
text = (sys.stdin.read() if req.source == "-" else req.source).strip()
|
|
451
|
+
source = StringSession(text)
|
|
452
|
+
target = SQLiteSession(str(manager.paths.session(alias)))
|
|
453
|
+
target.set_dc(source.dc_id, source.server_address, source.port)
|
|
454
|
+
target.auth_key = source.auth_key
|
|
455
|
+
target.save()
|
|
456
|
+
else:
|
|
457
|
+
shutil.copy2(req.source, destination)
|
|
458
|
+
secure_session_files(manager.paths.session(alias))
|
|
459
|
+
session = await _auth.sessions(ctx).ensure(alias)
|
|
460
|
+
client = await session.acquire(timeout=60)
|
|
461
|
+
if not await client.is_user_authorized():
|
|
462
|
+
raise AuthenticationError("the imported session is not authorized")
|
|
463
|
+
me = await client.get_me()
|
|
464
|
+
except Exception:
|
|
465
|
+
manager.remove_account(alias)
|
|
466
|
+
raise
|
|
467
|
+
manager.update_account(
|
|
468
|
+
alias,
|
|
469
|
+
phone=getattr(me, "phone", None),
|
|
470
|
+
username=getattr(me, "username", None),
|
|
471
|
+
first_name=getattr(me, "first_name", None),
|
|
472
|
+
user_id=getattr(me, "id", None),
|
|
473
|
+
)
|
|
474
|
+
return AccountState(
|
|
475
|
+
alias=alias,
|
|
476
|
+
account=alias,
|
|
477
|
+
user_id=getattr(me, "id", None),
|
|
478
|
+
username=getattr(me, "username", None),
|
|
479
|
+
authorized=True,
|
|
480
|
+
imported=True,
|
|
481
|
+
ok=True,
|
|
482
|
+
)
|
|
483
|
+
|
|
484
|
+
|
|
485
|
+
SPEC_IMPORT = OperationSpec(
|
|
486
|
+
id="account.import",
|
|
487
|
+
request=ImportReq,
|
|
488
|
+
response=AccountState,
|
|
489
|
+
impl=import_session,
|
|
490
|
+
summary="Import an existing Telethon session (file or StringSession)",
|
|
491
|
+
legacy_paths=("account import",),
|
|
492
|
+
mutating=True,
|
|
493
|
+
needs_account=False,
|
|
494
|
+
needs_auth=False,
|
|
495
|
+
rate_class="resolve",
|
|
496
|
+
columns=("alias", "user_id", "username", "authorized"),
|
|
497
|
+
example={"alias": "work", "user_id": 4242, "username": "me", "authorized": True},
|
|
498
|
+
example_args="account import ./work.session --alias work",
|
|
499
|
+
covers=("auth.session-import",),
|
|
500
|
+
)
|
|
501
|
+
|
|
502
|
+
|
|
503
|
+
class ExportReq(Request):
|
|
504
|
+
alias: Annotated[
|
|
505
|
+
str | None, arg(0, metavar="ACCOUNT", required=False, help="Which account.")
|
|
506
|
+
] = None
|
|
507
|
+
format: Annotated[str | None, choice("string", "file", help="Output form.")] = "string"
|
|
508
|
+
out: Annotated[
|
|
509
|
+
str | None, opt("--out", metavar="PATH", kind="path", help="Write here at 0600.")
|
|
510
|
+
] = None
|
|
511
|
+
stdout: Annotated[
|
|
512
|
+
bool, opt("--stdout", help="Print the credential to stdout — it is a bearer token.")
|
|
513
|
+
] = False
|
|
514
|
+
|
|
515
|
+
|
|
516
|
+
async def export(ctx: OpContext, req: ExportReq) -> AccountState:
|
|
517
|
+
"""Export an authorization as a StringSession or a session file copy.
|
|
518
|
+
|
|
519
|
+
The exported value **is** the account: anyone holding it can act as you
|
|
520
|
+
until the session is terminated. It therefore goes to a 0600 file unless
|
|
521
|
+
`--stdout` says out loud that printing it is intended. Using the same
|
|
522
|
+
auth key from two live connections earns `AUTH_KEY_DUPLICATED`, which is
|
|
523
|
+
why tlgr routes everything through one daemon.
|
|
524
|
+
"""
|
|
525
|
+
from pathlib import Path
|
|
526
|
+
|
|
527
|
+
from telethon.sessions import StringSession
|
|
528
|
+
|
|
529
|
+
if not req.stdout and not req.out:
|
|
530
|
+
raise UsageError(
|
|
531
|
+
"an exported session is a bearer credential: write it with --out PATH (0600), "
|
|
532
|
+
"or pass --stdout to print it deliberately",
|
|
533
|
+
field="out",
|
|
534
|
+
)
|
|
535
|
+
manager = _auth.accounts(ctx)
|
|
536
|
+
alias = _auth.resolve_alias(ctx, req.alias)
|
|
537
|
+
ctx.warn(
|
|
538
|
+
"this is a full authorization; anyone holding it can act as this account until "
|
|
539
|
+
"the session is terminated"
|
|
540
|
+
)
|
|
541
|
+
if req.format == "file":
|
|
542
|
+
source = manager.paths.session_file(alias)
|
|
543
|
+
if not req.out:
|
|
544
|
+
raise UsageError("--format file needs --out PATH", field="out")
|
|
545
|
+
shutil.copy2(source, req.out)
|
|
546
|
+
Path(req.out).chmod(0o600)
|
|
547
|
+
return AccountState(alias=alias, account=alias, format="file", path=str(req.out), ok=True)
|
|
548
|
+
|
|
549
|
+
text = StringSession.save(_auth.client(ctx).session)
|
|
550
|
+
if req.out:
|
|
551
|
+
write_private(Path(req.out), text)
|
|
552
|
+
return AccountState(alias=alias, account=alias, format="string", path=str(req.out), ok=True)
|
|
553
|
+
return AccountState(alias=alias, account=alias, format="string", session=text, ok=True)
|
|
554
|
+
|
|
555
|
+
|
|
556
|
+
SPEC_EXPORT = OperationSpec(
|
|
557
|
+
id="account.export",
|
|
558
|
+
request=ExportReq,
|
|
559
|
+
response=AccountState,
|
|
560
|
+
impl=export,
|
|
561
|
+
summary="Export an account's authorization as a session file or StringSession",
|
|
562
|
+
mutating=False,
|
|
563
|
+
rate_class="local",
|
|
564
|
+
columns=("alias", "format", "path"),
|
|
565
|
+
example={"alias": "work", "format": "string", "path": "/home/me/work.session"},
|
|
566
|
+
example_args="account export work --out ./work.string",
|
|
567
|
+
covers=("auth.session-export", "auth.single-connection-guard"),
|
|
568
|
+
tags=frozenset({"bearer-credential"}),
|
|
569
|
+
)
|
|
570
|
+
|
|
571
|
+
|
|
572
|
+
class LogoutReq(Request):
|
|
573
|
+
alias: Annotated[
|
|
574
|
+
str | None, arg(0, metavar="ACCOUNT", required=False, help="Which account.")
|
|
575
|
+
] = None
|
|
576
|
+
keep_local: Annotated[
|
|
577
|
+
bool, opt("--keep-local", help="Keep the (now invalid) session file.")
|
|
578
|
+
] = False
|
|
579
|
+
keep_token: Annotated[
|
|
580
|
+
bool,
|
|
581
|
+
opt(
|
|
582
|
+
"--keep-token/--no-keep-token",
|
|
583
|
+
help="Keep the future_auth_token for a code-less re-login.",
|
|
584
|
+
),
|
|
585
|
+
] = True
|
|
586
|
+
|
|
587
|
+
|
|
588
|
+
async def logout(ctx: OpContext, req: LogoutReq) -> AccountState:
|
|
589
|
+
"""Revoke the authorization on the server, and drop the dead session file.
|
|
590
|
+
|
|
591
|
+
This is the gap v1 had: `account remove` deleted the local files and
|
|
592
|
+
never called `auth.logOut`, so the authorization stayed alive in every
|
|
593
|
+
other client's Devices list forever. Logging out is *not* removing the
|
|
594
|
+
account — the alias, its credentials and its future auth token stay, so
|
|
595
|
+
`tlgr auth send-code` can log back in.
|
|
596
|
+
"""
|
|
597
|
+
from telethon.tl.functions import auth as fn
|
|
598
|
+
|
|
599
|
+
manager = _auth.accounts(ctx)
|
|
600
|
+
alias = _auth.resolve_alias(ctx, req.alias)
|
|
601
|
+
sessions = _auth.sessions(ctx)
|
|
602
|
+
session = await sessions.ensure(alias)
|
|
603
|
+
client = await session.acquire(timeout=60)
|
|
604
|
+
answer = await client(fn.LogOutRequest())
|
|
605
|
+
stored = False
|
|
606
|
+
if req.keep_token:
|
|
607
|
+
stored = _auth.store_future_token(
|
|
608
|
+
manager, alias, getattr(answer, "future_auth_token", None)
|
|
609
|
+
)
|
|
610
|
+
await sessions.release(alias)
|
|
611
|
+
if not req.keep_local:
|
|
612
|
+
for path in (manager.paths.session_file(alias), manager.paths.session(alias)):
|
|
613
|
+
with contextlib.suppress(OSError):
|
|
614
|
+
path.unlink(missing_ok=True)
|
|
615
|
+
manager.set_health(alias, "needs_login", reason="logged out")
|
|
616
|
+
_auth.emit(ctx, "logged_out", {"alias": alias})
|
|
617
|
+
return AccountState(
|
|
618
|
+
alias=alias,
|
|
619
|
+
account=alias,
|
|
620
|
+
ok=True,
|
|
621
|
+
logged_out=True,
|
|
622
|
+
future_auth_token_stored=stored,
|
|
623
|
+
hint=(
|
|
624
|
+
"the alias is still configured; log back in with tlgr auth send-code, "
|
|
625
|
+
"or delete it entirely with tlgr account remove"
|
|
626
|
+
),
|
|
627
|
+
)
|
|
628
|
+
|
|
629
|
+
|
|
630
|
+
SPEC_LOGOUT = OperationSpec(
|
|
631
|
+
id="account.logout",
|
|
632
|
+
request=LogoutReq,
|
|
633
|
+
response=AccountState,
|
|
634
|
+
impl=logout,
|
|
635
|
+
summary="Log out: revoke the authorization on the server and drop the session",
|
|
636
|
+
description=(
|
|
637
|
+
"v1's `account remove` never called `auth.logOut`, so a removed "
|
|
638
|
+
"account went on showing up in every other client's Devices list. "
|
|
639
|
+
"The returned `future_auth_token` is a bearer secret: 0600 next to "
|
|
640
|
+
"the session, capped at 20, dropped by `account remove`."
|
|
641
|
+
),
|
|
642
|
+
aliases=("logout",),
|
|
643
|
+
legacy_paths=("logout",),
|
|
644
|
+
mutating=True,
|
|
645
|
+
destructive=True,
|
|
646
|
+
needs_account=False,
|
|
647
|
+
needs_auth=False,
|
|
648
|
+
rate_class="resolve",
|
|
649
|
+
columns=("alias", "logged_out", "future_auth_token_stored"),
|
|
650
|
+
example={"alias": "work", "logged_out": True, "future_auth_token_stored": True},
|
|
651
|
+
example_args="account logout work",
|
|
652
|
+
covers=("auth.log-out",),
|
|
653
|
+
covers_partial=(
|
|
654
|
+
"auth.clear-local-data",
|
|
655
|
+
"auth.future-auth-tokens",
|
|
656
|
+
"auth.logout-alternatives",
|
|
657
|
+
),
|
|
658
|
+
coverage_note=(
|
|
659
|
+
"Wiping the alias is `account remove`; the token is minted by "
|
|
660
|
+
"`auth send-code`; the support alternative is `account support get`."
|
|
661
|
+
),
|
|
662
|
+
)
|
|
663
|
+
|
|
664
|
+
|
|
665
|
+
class RemoveReq(Request):
|
|
666
|
+
alias: Annotated[str, arg(0, metavar="ACCOUNT", help="Which account.")]
|
|
667
|
+
server_logout: Annotated[
|
|
668
|
+
bool,
|
|
669
|
+
opt("--logout/--no-server-logout", help="Also revoke the authorization on the server."),
|
|
670
|
+
] = False
|
|
671
|
+
|
|
672
|
+
|
|
673
|
+
async def remove(ctx: OpContext, req: RemoveReq) -> AccountState:
|
|
674
|
+
"""Remove an account from this machine. Local only unless `--logout`.
|
|
675
|
+
|
|
676
|
+
v1's behaviour is preserved and now explicit: without `--logout` the
|
|
677
|
+
authorization keeps showing up in every other client's Devices list, and
|
|
678
|
+
the answer says so instead of leaving you to find out.
|
|
679
|
+
"""
|
|
680
|
+
manager = _auth.accounts(ctx)
|
|
681
|
+
alias = validate_alias(req.alias)
|
|
682
|
+
if manager.get_account(alias) is None:
|
|
683
|
+
raise AccountNotFoundError(f"no account named {alias!r}. Run: tlgr account list")
|
|
684
|
+
logged_out = False
|
|
685
|
+
if req.server_logout:
|
|
686
|
+
await logout(ctx, LogoutReq(alias=alias, keep_token=False))
|
|
687
|
+
logged_out = True
|
|
688
|
+
else:
|
|
689
|
+
sessions = getattr(getattr(ctx, "daemon", None), "sessions", None)
|
|
690
|
+
if sessions is not None:
|
|
691
|
+
with contextlib.suppress(Exception):
|
|
692
|
+
await sessions.release(alias)
|
|
693
|
+
manager.remove_account(alias)
|
|
694
|
+
return AccountState(
|
|
695
|
+
alias=alias,
|
|
696
|
+
account=alias,
|
|
697
|
+
ok=True,
|
|
698
|
+
removed=True,
|
|
699
|
+
server_logout=logged_out,
|
|
700
|
+
hint=(
|
|
701
|
+
None
|
|
702
|
+
if logged_out
|
|
703
|
+
else "the server-side authorization is still alive and still listed in other "
|
|
704
|
+
"clients' Devices. Pass --logout to revoke it."
|
|
705
|
+
),
|
|
706
|
+
)
|
|
707
|
+
|
|
708
|
+
|
|
709
|
+
SPEC_REMOVE = OperationSpec(
|
|
710
|
+
id="account.remove",
|
|
711
|
+
request=RemoveReq,
|
|
712
|
+
response=AccountState,
|
|
713
|
+
impl=remove,
|
|
714
|
+
summary="Remove an account from this machine (local only unless --logout)",
|
|
715
|
+
legacy_paths=("account remove",),
|
|
716
|
+
mutating=True,
|
|
717
|
+
destructive=True,
|
|
718
|
+
needs_account=False,
|
|
719
|
+
needs_auth=False,
|
|
720
|
+
rate_class="local",
|
|
721
|
+
columns=("alias", "removed", "server_logout"),
|
|
722
|
+
example={"alias": "work", "removed": True, "server_logout": False},
|
|
723
|
+
example_args="account remove work",
|
|
724
|
+
covers=("auth.clear-local-data",),
|
|
725
|
+
)
|
|
726
|
+
|
|
727
|
+
|
|
728
|
+
class CheckReq(Request):
|
|
729
|
+
alias: Annotated[
|
|
730
|
+
str | None,
|
|
731
|
+
arg(0, metavar="ACCOUNT", required=False, help="One account; omit to check every one."),
|
|
732
|
+
] = None
|
|
733
|
+
|
|
734
|
+
|
|
735
|
+
async def check(ctx: OpContext, req: CheckReq) -> Page[AccountState]:
|
|
736
|
+
"""Is the stored authorization still good — and if not, why not?
|
|
737
|
+
|
|
738
|
+
The distinction `daemon status` cannot make: "the network is down" and
|
|
739
|
+
"Telegram revoked this auth key" both look like a disconnected client,
|
|
740
|
+
and only one of them is fixed by waiting. `revoked` is exit 4 material,
|
|
741
|
+
`banned`/`frozen` is exit 9, and `offline` is exit 8 — but this command
|
|
742
|
+
*reports* rather than raises, because `--all` has to answer for every
|
|
743
|
+
account even when one of them is dead.
|
|
744
|
+
"""
|
|
745
|
+
manager = _auth.accounts(ctx)
|
|
746
|
+
aliases = [validate_alias(req.alias)] if req.alias else manager.aliases()
|
|
747
|
+
sessions = _auth.sessions(ctx)
|
|
748
|
+
items: list[AccountState] = []
|
|
749
|
+
for alias in aliases:
|
|
750
|
+
items.append(await _check_one(ctx, manager, sessions, alias))
|
|
751
|
+
return Page(items=items, has_more=False, total=len(items))
|
|
752
|
+
|
|
753
|
+
|
|
754
|
+
async def _check_one(ctx: OpContext, manager: Any, sessions: Any, alias: str) -> AccountState:
|
|
755
|
+
from tlgr.core.errors import rule_for
|
|
756
|
+
|
|
757
|
+
state = AccountState(alias=alias, account=alias)
|
|
758
|
+
try:
|
|
759
|
+
session = await sessions.ensure(alias)
|
|
760
|
+
client = await session.acquire(timeout=60)
|
|
761
|
+
if not await client.is_user_authorized():
|
|
762
|
+
state.state = "revoked"
|
|
763
|
+
state.hint = "run: tlgr auth send-code <phone> to log in again"
|
|
764
|
+
return state
|
|
765
|
+
me = await client.get_me()
|
|
766
|
+
state.state = "authorized"
|
|
767
|
+
state.user_id = getattr(me, "id", None)
|
|
768
|
+
state.username = getattr(me, "username", None)
|
|
769
|
+
return state
|
|
770
|
+
except Exception as exc:
|
|
771
|
+
rule = rule_for(exc)
|
|
772
|
+
message = str(exc)
|
|
773
|
+
state.error = message
|
|
774
|
+
if "FROZEN" in message:
|
|
775
|
+
state.state = "frozen"
|
|
776
|
+
config = {}
|
|
777
|
+
with contextlib.suppress(Exception):
|
|
778
|
+
config = await _auth.app_config(await sessions.get(alias).acquire(timeout=10))
|
|
779
|
+
state.frozen_since = _auth.iso(_unix(config.get("freeze_since_date")))
|
|
780
|
+
state.frozen_until = _auth.iso(_unix(config.get("freeze_until_date")))
|
|
781
|
+
state.appeal_url = str(config.get("freeze_appeal_url") or "") or None
|
|
782
|
+
state.hint = "appeal at the URL above; the form is a web page a CLI cannot submit"
|
|
783
|
+
elif "USER_DEACTIVATED_BAN" in message:
|
|
784
|
+
state.state = "banned"
|
|
785
|
+
state.hint = "write to recover@telegram.org — this is not something a client can undo"
|
|
786
|
+
elif "USER_DEACTIVATED" in message:
|
|
787
|
+
state.state = "deactivated"
|
|
788
|
+
state.hint = "write to recover@telegram.org"
|
|
789
|
+
elif rule.code in ("SESSION_ERROR", "AUTH_ERROR"):
|
|
790
|
+
state.state = "revoked"
|
|
791
|
+
state.hint = "run: tlgr auth send-code <phone> to log in again"
|
|
792
|
+
else:
|
|
793
|
+
state.state = "offline"
|
|
794
|
+
state.hint = "the account could not be reached; this is not a statement about it"
|
|
795
|
+
return state
|
|
796
|
+
|
|
797
|
+
|
|
798
|
+
def _unix(value: Any) -> Any:
|
|
799
|
+
from datetime import datetime, timezone
|
|
800
|
+
|
|
801
|
+
if isinstance(value, (int, float)) and value:
|
|
802
|
+
return datetime.fromtimestamp(float(value), tz=timezone.utc)
|
|
803
|
+
return None
|
|
804
|
+
|
|
805
|
+
|
|
806
|
+
SPEC_CHECK = OperationSpec(
|
|
807
|
+
id="account.check",
|
|
808
|
+
request=CheckReq,
|
|
809
|
+
response=Page[AccountState],
|
|
810
|
+
impl=check,
|
|
811
|
+
summary="Health-check the stored authorization: authorized, revoked, banned or frozen",
|
|
812
|
+
description=(
|
|
813
|
+
"Reports rather than raises, so one dead account cannot hide the "
|
|
814
|
+
"health of the others. `frozen` carries Telegram's own appeal URL "
|
|
815
|
+
"from `help.getAppConfig`; the appeal itself is a web form."
|
|
816
|
+
),
|
|
817
|
+
paginated=PageKind.LOCAL,
|
|
818
|
+
needs_account=False,
|
|
819
|
+
needs_auth=False,
|
|
820
|
+
rate_class="read",
|
|
821
|
+
columns=("alias", "state", "user_id", "hint"),
|
|
822
|
+
headers=("Alias", "State", "User ID", "What to do"),
|
|
823
|
+
example={"items": [{"alias": "work", "state": "authorized", "user_id": 4242}]},
|
|
824
|
+
example_args="account check",
|
|
825
|
+
covers=(
|
|
826
|
+
"account.deactivated-banned",
|
|
827
|
+
"account.frozen-appeal",
|
|
828
|
+
"account.frozen-state",
|
|
829
|
+
"auth.session-health",
|
|
830
|
+
),
|
|
831
|
+
tags=frozenset({"agent-safe"}),
|
|
832
|
+
)
|
|
833
|
+
|
|
834
|
+
|
|
835
|
+
# ---------------------------------------------------------------------------
|
|
836
|
+
# account info / sync
|
|
837
|
+
# ---------------------------------------------------------------------------
|
|
838
|
+
|
|
839
|
+
|
|
840
|
+
class InfoReq(Request):
|
|
841
|
+
alias: Annotated[
|
|
842
|
+
str | None, arg(0, metavar="ACCOUNT", required=False, help="Which account.")
|
|
843
|
+
] = None
|
|
844
|
+
|
|
845
|
+
|
|
846
|
+
async def info(ctx: OpContext, req: InfoReq) -> AccountState:
|
|
847
|
+
"""The active account: who it is, which DC it lives on, where its session is."""
|
|
848
|
+
manager = _auth.accounts(ctx)
|
|
849
|
+
alias = _auth.resolve_alias(ctx, req.alias)
|
|
850
|
+
client = _auth.client(ctx)
|
|
851
|
+
me = await client.get_me()
|
|
852
|
+
record = manager.get_account(alias)
|
|
853
|
+
return AccountState(
|
|
854
|
+
alias=alias,
|
|
855
|
+
account=alias,
|
|
856
|
+
user_id=getattr(me, "id", None),
|
|
857
|
+
username=getattr(me, "username", None),
|
|
858
|
+
first_name=getattr(me, "first_name", None),
|
|
859
|
+
phone=getattr(me, "phone", None),
|
|
860
|
+
premium=bool(getattr(me, "premium", False)),
|
|
861
|
+
dc_id=getattr(getattr(client, "session", None), "dc_id", None),
|
|
862
|
+
kind="bot" if getattr(me, "bot", False) else _kind_of(manager, alias),
|
|
863
|
+
session_path=str(manager.paths.session_file(alias)),
|
|
864
|
+
created_at=record.created_at if record else None,
|
|
865
|
+
state=record.health.state if record else "unknown",
|
|
866
|
+
)
|
|
867
|
+
|
|
868
|
+
|
|
869
|
+
SPEC_INFO = OperationSpec(
|
|
870
|
+
id="account.info",
|
|
871
|
+
request=InfoReq,
|
|
872
|
+
response=AccountState,
|
|
873
|
+
impl=info,
|
|
874
|
+
summary="Show the active account: user, phone, dc, session file and state",
|
|
875
|
+
legacy_paths=("account info",),
|
|
876
|
+
rate_class="read",
|
|
877
|
+
columns=("alias", "user_id", "username", "phone", "dc_id", "premium"),
|
|
878
|
+
example={
|
|
879
|
+
"alias": "work",
|
|
880
|
+
"user_id": 4242,
|
|
881
|
+
"username": "me",
|
|
882
|
+
"phone": "+989123456789",
|
|
883
|
+
"dc_id": 4,
|
|
884
|
+
"premium": False,
|
|
885
|
+
},
|
|
886
|
+
example_args="account info",
|
|
887
|
+
covers=("dialogs.frozen-account",),
|
|
888
|
+
coverage_note="The frozen state itself is established by `account check`.",
|
|
889
|
+
tags=frozenset({"agent-safe"}),
|
|
890
|
+
)
|
|
891
|
+
|
|
892
|
+
|
|
893
|
+
class SyncReq(Request):
|
|
894
|
+
full: Annotated[bool, opt("--full", help="Re-walk every dialog, not an incremental pass.")] = (
|
|
895
|
+
False
|
|
896
|
+
)
|
|
897
|
+
|
|
898
|
+
|
|
899
|
+
async def sync(ctx: OpContext, req: SyncReq) -> AccountState:
|
|
900
|
+
"""Refresh the cached entities, dialogs and update state for an account."""
|
|
901
|
+
from telethon.tl.functions import updates as fn
|
|
902
|
+
|
|
903
|
+
client = _auth.client(ctx)
|
|
904
|
+
manager = _auth.accounts(ctx)
|
|
905
|
+
alias = _auth.resolve_alias(ctx)
|
|
906
|
+
dialogs = 0
|
|
907
|
+
users = 0
|
|
908
|
+
chats = 0
|
|
909
|
+
async for dialog in client.iter_dialogs(limit=None if req.full else 200):
|
|
910
|
+
dialogs += 1
|
|
911
|
+
entity = getattr(dialog, "entity", None)
|
|
912
|
+
if type(entity).__name__ == "User":
|
|
913
|
+
users += 1
|
|
914
|
+
elif entity is not None:
|
|
915
|
+
chats += 1
|
|
916
|
+
state = await client(fn.GetStateRequest())
|
|
917
|
+
me = await client.get_me()
|
|
918
|
+
manager.update_account(
|
|
919
|
+
alias,
|
|
920
|
+
phone=getattr(me, "phone", None),
|
|
921
|
+
username=getattr(me, "username", None),
|
|
922
|
+
first_name=getattr(me, "first_name", None),
|
|
923
|
+
user_id=getattr(me, "id", None),
|
|
924
|
+
)
|
|
925
|
+
return AccountState(
|
|
926
|
+
alias=alias,
|
|
927
|
+
account=alias,
|
|
928
|
+
ok=True,
|
|
929
|
+
dialogs=dialogs,
|
|
930
|
+
users=users,
|
|
931
|
+
chats=chats,
|
|
932
|
+
pts=getattr(state, "pts", None),
|
|
933
|
+
user_id=getattr(me, "id", None),
|
|
934
|
+
username=getattr(me, "username", None),
|
|
935
|
+
)
|
|
936
|
+
|
|
937
|
+
|
|
938
|
+
SPEC_SYNC = OperationSpec(
|
|
939
|
+
id="account.sync",
|
|
940
|
+
request=SyncReq,
|
|
941
|
+
response=AccountState,
|
|
942
|
+
impl=sync,
|
|
943
|
+
summary="Refresh cached entities, dialogs and update state for an account",
|
|
944
|
+
legacy_paths=("account sync",),
|
|
945
|
+
mutating=True,
|
|
946
|
+
idempotent=True,
|
|
947
|
+
rate_class="bulk",
|
|
948
|
+
timeout_s=300,
|
|
949
|
+
columns=("ok", "dialogs", "users", "chats", "pts"),
|
|
950
|
+
example={"ok": True, "dialogs": 42, "users": 30, "chats": 12, "pts": 90210},
|
|
951
|
+
example_args="account sync",
|
|
952
|
+
covers=(),
|
|
953
|
+
tags=frozenset({"agent-safe", "infrastructure"}),
|
|
954
|
+
)
|
|
955
|
+
|
|
956
|
+
|
|
957
|
+
# ---------------------------------------------------------------------------
|
|
958
|
+
# account session * — the Devices list
|
|
959
|
+
# ---------------------------------------------------------------------------
|
|
960
|
+
|
|
961
|
+
|
|
962
|
+
class SessionListReq(Request):
|
|
963
|
+
hash: Annotated[
|
|
964
|
+
str | None,
|
|
965
|
+
arg(0, metavar="HASH", required=False, help="One session hash, or 'current'."),
|
|
966
|
+
] = None
|
|
967
|
+
unconfirmed: Annotated[
|
|
968
|
+
bool, opt("--unconfirmed", help="Only sessions still awaiting 'Yes, it's me'.")
|
|
969
|
+
] = False
|
|
970
|
+
pending_password: Annotated[
|
|
971
|
+
bool, opt("--pending-password", help="Only incomplete logins (password_pending).")
|
|
972
|
+
] = False
|
|
973
|
+
bots: Annotated[bool, opt("--bots", help="Also list connected business bots.")] = False
|
|
974
|
+
|
|
975
|
+
|
|
976
|
+
async def session_list(ctx: OpContext, req: SessionListReq) -> Page[Session]:
|
|
977
|
+
"""List active sessions (Devices), or show one in detail.
|
|
978
|
+
|
|
979
|
+
`hash` is the id every per-session action takes. Two fields are derived
|
|
980
|
+
rather than left to the reader: `deny_deadline`, because an unconfirmed
|
|
981
|
+
login stops being deniable once Telegram auto-confirms it, so a security
|
|
982
|
+
cron that runs less often than `authorization_autoconfirm_period` will
|
|
983
|
+
never see one; and `sensitive_actions_eligible_at`, which is what
|
|
984
|
+
`SESSION_TOO_FRESH_X` counts down to before an ownership transfer.
|
|
985
|
+
"""
|
|
986
|
+
from telethon.tl.functions import account as fn
|
|
987
|
+
|
|
988
|
+
client = _auth.client(ctx)
|
|
989
|
+
answer = await client(fn.GetAuthorizationsRequest())
|
|
990
|
+
config = await _auth.app_config(client)
|
|
991
|
+
period = int(config.get("authorization_autoconfirm_period") or 0)
|
|
992
|
+
ttl = getattr(answer, "authorization_ttl_days", None)
|
|
993
|
+
items = [
|
|
994
|
+
_auth.session_model(auth, ttl_days=ttl, autoconfirm_period=period)
|
|
995
|
+
for auth in getattr(answer, "authorizations", None) or []
|
|
996
|
+
]
|
|
997
|
+
if req.hash:
|
|
998
|
+
wanted = req.hash.strip().lower()
|
|
999
|
+
items = [
|
|
1000
|
+
item
|
|
1001
|
+
for item in items
|
|
1002
|
+
if (item.current if wanted == "current" else item.hash == req.hash.strip())
|
|
1003
|
+
]
|
|
1004
|
+
if req.unconfirmed:
|
|
1005
|
+
items = [item for item in items if item.unconfirmed]
|
|
1006
|
+
if req.pending_password:
|
|
1007
|
+
items = [item for item in items if item.password_pending]
|
|
1008
|
+
if req.bots:
|
|
1009
|
+
items.extend(await _connected_bots(ctx, client))
|
|
1010
|
+
return Page(items=items, has_more=False, total=len(items))
|
|
1011
|
+
|
|
1012
|
+
|
|
1013
|
+
async def _connected_bots(ctx: OpContext, client: Any) -> list[Session]:
|
|
1014
|
+
"""Business bots, which official clients show in the same Devices list.
|
|
1015
|
+
|
|
1016
|
+
Premium/business only, so a failure here is a warning rather than an
|
|
1017
|
+
error: the Devices list is still the answer to the question that was
|
|
1018
|
+
asked.
|
|
1019
|
+
"""
|
|
1020
|
+
from telethon.tl.functions import account as fn
|
|
1021
|
+
|
|
1022
|
+
try:
|
|
1023
|
+
answer = await client(fn.GetConnectedBotsRequest())
|
|
1024
|
+
except Exception as exc:
|
|
1025
|
+
ctx.warn(f"connected business bots are unavailable on this account: {exc}")
|
|
1026
|
+
return []
|
|
1027
|
+
users = {user.id: user for user in getattr(answer, "users", None) or []}
|
|
1028
|
+
return [
|
|
1029
|
+
Session(
|
|
1030
|
+
hash=str(getattr(bot, "bot_id", "")),
|
|
1031
|
+
bot=True,
|
|
1032
|
+
bot_username=getattr(users.get(getattr(bot, "bot_id", 0)), "username", None),
|
|
1033
|
+
device_model=getattr(bot, "device", "") or "",
|
|
1034
|
+
app_name="business bot",
|
|
1035
|
+
date_created=_auth.iso(getattr(bot, "date", None)),
|
|
1036
|
+
)
|
|
1037
|
+
for bot in getattr(answer, "connected_bots", None) or []
|
|
1038
|
+
]
|
|
1039
|
+
|
|
1040
|
+
|
|
1041
|
+
SPEC_SESSION_LIST = OperationSpec(
|
|
1042
|
+
id="account.session.list",
|
|
1043
|
+
request=SessionListReq,
|
|
1044
|
+
response=Page[Session],
|
|
1045
|
+
impl=session_list,
|
|
1046
|
+
summary="List active sessions (Devices), or show one in detail",
|
|
1047
|
+
aliases=("session.list", "sessions.list"),
|
|
1048
|
+
paginated=PageKind.LOCAL,
|
|
1049
|
+
rate_class="read",
|
|
1050
|
+
columns=("hash", "device_model", "app_name", "ip", "country", "date_active", "current"),
|
|
1051
|
+
headers=("Hash", "Device", "App", "IP", "Country", "Last active", "This one"),
|
|
1052
|
+
example={
|
|
1053
|
+
"items": [
|
|
1054
|
+
{
|
|
1055
|
+
"hash": "0",
|
|
1056
|
+
"current": True,
|
|
1057
|
+
"device_model": "tlgr@host",
|
|
1058
|
+
"app_name": "tlgr",
|
|
1059
|
+
"ip": "203.0.113.7",
|
|
1060
|
+
"country": "NL",
|
|
1061
|
+
"date_active": "2026-09-03T09:14:07Z",
|
|
1062
|
+
}
|
|
1063
|
+
]
|
|
1064
|
+
},
|
|
1065
|
+
example_args="account session list",
|
|
1066
|
+
covers=(
|
|
1067
|
+
"auth.session-age-requirement",
|
|
1068
|
+
"sessions.autoconfirm-period",
|
|
1069
|
+
"sessions.business-bot-entry",
|
|
1070
|
+
"sessions.incomplete-login-attempts",
|
|
1071
|
+
"sessions.list",
|
|
1072
|
+
"sessions.new-login-alert",
|
|
1073
|
+
"sessions.show",
|
|
1074
|
+
),
|
|
1075
|
+
tags=frozenset({"agent-safe"}),
|
|
1076
|
+
)
|
|
1077
|
+
|
|
1078
|
+
|
|
1079
|
+
class SessionTerminateReq(Request):
|
|
1080
|
+
hash: Annotated[
|
|
1081
|
+
tuple[str, ...],
|
|
1082
|
+
arg(0, metavar="HASH", required=False, variadic=True, help="Sessions to terminate."),
|
|
1083
|
+
] = ()
|
|
1084
|
+
all_others: Annotated[
|
|
1085
|
+
bool, opt("--all-others", help="Log out every session except this one.")
|
|
1086
|
+
] = False
|
|
1087
|
+
deny: Annotated[
|
|
1088
|
+
bool, opt("--deny", help="'It wasn't me': terminate and print the password advice.")
|
|
1089
|
+
] = False
|
|
1090
|
+
|
|
1091
|
+
|
|
1092
|
+
async def session_terminate(ctx: OpContext, req: SessionTerminateReq) -> SessionTermination:
|
|
1093
|
+
"""Terminate one session, several, or every session but this one.
|
|
1094
|
+
|
|
1095
|
+
The current session cannot be terminated this way — that is `account
|
|
1096
|
+
logout`. `FRESH_RESET_AUTHORISATION_FORBIDDEN` means *this* session is
|
|
1097
|
+
younger than 24 hours; the wait is Telegram's, not tlgr's.
|
|
1098
|
+
"""
|
|
1099
|
+
from telethon.tl.functions import account as fn
|
|
1100
|
+
from telethon.tl.functions import auth as auth_fn
|
|
1101
|
+
|
|
1102
|
+
client = _auth.client(ctx)
|
|
1103
|
+
if req.all_others:
|
|
1104
|
+
await client(auth_fn.ResetAuthorizationsRequest())
|
|
1105
|
+
ctx.emit("sessions_terminated", {"all_others": True})
|
|
1106
|
+
return SessionTermination(
|
|
1107
|
+
terminated=-1,
|
|
1108
|
+
advice="every other session was logged out; the server reports no count",
|
|
1109
|
+
)
|
|
1110
|
+
if not req.hash:
|
|
1111
|
+
raise UsageError("give one or more session hashes, or --all-others", field="hash")
|
|
1112
|
+
done: list[str] = []
|
|
1113
|
+
for value in req.hash:
|
|
1114
|
+
await client(fn.ResetAuthorizationRequest(hash=_auth.parse_hash(value)))
|
|
1115
|
+
done.append(value)
|
|
1116
|
+
ctx.emit("sessions_terminated", {"hashes": done})
|
|
1117
|
+
return SessionTermination(
|
|
1118
|
+
terminated=len(done),
|
|
1119
|
+
hashes=done,
|
|
1120
|
+
advice=(
|
|
1121
|
+
"if that login was not you, change the cloud password too: tlgr account password change"
|
|
1122
|
+
if req.deny
|
|
1123
|
+
else None
|
|
1124
|
+
),
|
|
1125
|
+
)
|
|
1126
|
+
|
|
1127
|
+
|
|
1128
|
+
SPEC_SESSION_TERMINATE = OperationSpec(
|
|
1129
|
+
id="account.session.terminate",
|
|
1130
|
+
request=SessionTerminateReq,
|
|
1131
|
+
response=SessionTermination,
|
|
1132
|
+
impl=session_terminate,
|
|
1133
|
+
summary="Terminate one session, several, or every session but this one",
|
|
1134
|
+
aliases=("session.terminate", "sessions.terminate"),
|
|
1135
|
+
mutating=True,
|
|
1136
|
+
destructive=True,
|
|
1137
|
+
rate_class="send",
|
|
1138
|
+
columns=("terminated", "hashes"),
|
|
1139
|
+
example={"terminated": 1, "hashes": ["9021045"]},
|
|
1140
|
+
example_args="account session terminate 9021045",
|
|
1141
|
+
covers=("sessions.deny-new-login", "sessions.terminate", "sessions.terminate-all-others"),
|
|
1142
|
+
)
|
|
1143
|
+
|
|
1144
|
+
|
|
1145
|
+
class SessionSetReq(Request):
|
|
1146
|
+
hash: Annotated[
|
|
1147
|
+
str | None,
|
|
1148
|
+
arg(0, metavar="HASH", required=False, help="Omit to change account-wide settings only."),
|
|
1149
|
+
] = None
|
|
1150
|
+
calls: Annotated[
|
|
1151
|
+
str | None, choice("on", "off", help="Let this session accept incoming calls.")
|
|
1152
|
+
] = None
|
|
1153
|
+
secret_chats: Annotated[
|
|
1154
|
+
str | None, choice("on", "off", help="Let this session accept secret chats.")
|
|
1155
|
+
] = None
|
|
1156
|
+
auto_terminate: Annotated[
|
|
1157
|
+
str | None,
|
|
1158
|
+
opt(
|
|
1159
|
+
"--auto-terminate",
|
|
1160
|
+
metavar="DURATION",
|
|
1161
|
+
help="Account-wide: terminate sessions inactive this long (1-366 days).",
|
|
1162
|
+
),
|
|
1163
|
+
] = None
|
|
1164
|
+
|
|
1165
|
+
|
|
1166
|
+
async def session_set(ctx: OpContext, req: SessionSetReq) -> SessionChange:
|
|
1167
|
+
"""Per-session permissions, or the account-wide inactive-session TTL.
|
|
1168
|
+
|
|
1169
|
+
The TTL is account-wide, so it is a flag on this command rather than a
|
|
1170
|
+
fourth path level; `account session list` reports the current value on
|
|
1171
|
+
every row.
|
|
1172
|
+
"""
|
|
1173
|
+
from telethon.tl.functions import account as fn
|
|
1174
|
+
|
|
1175
|
+
client = _auth.client(ctx)
|
|
1176
|
+
change = SessionChange(hash=req.hash or "")
|
|
1177
|
+
if req.auto_terminate:
|
|
1178
|
+
days = _days(req.auto_terminate, low=1, high=366, field="auto_terminate")
|
|
1179
|
+
await client(fn.SetAuthorizationTTLRequest(authorization_ttl_days=days))
|
|
1180
|
+
change.authorization_ttl_days = days
|
|
1181
|
+
if req.calls is None and req.secret_chats is None:
|
|
1182
|
+
if req.auto_terminate is None:
|
|
1183
|
+
raise UsageError(
|
|
1184
|
+
"nothing to change: pass --calls, --secret-chats or --auto-terminate",
|
|
1185
|
+
field="calls",
|
|
1186
|
+
)
|
|
1187
|
+
return change
|
|
1188
|
+
if not req.hash:
|
|
1189
|
+
raise UsageError("--calls and --secret-chats need a session hash", field="hash")
|
|
1190
|
+
calls_disabled = None if req.calls is None else req.calls == "off"
|
|
1191
|
+
secret_disabled = None if req.secret_chats is None else req.secret_chats == "off"
|
|
1192
|
+
await client(
|
|
1193
|
+
fn.ChangeAuthorizationSettingsRequest(
|
|
1194
|
+
hash=_auth.parse_hash(req.hash),
|
|
1195
|
+
call_requests_disabled=calls_disabled,
|
|
1196
|
+
encrypted_requests_disabled=secret_disabled,
|
|
1197
|
+
)
|
|
1198
|
+
)
|
|
1199
|
+
change.call_requests_disabled = calls_disabled
|
|
1200
|
+
change.encrypted_requests_disabled = secret_disabled
|
|
1201
|
+
return change
|
|
1202
|
+
|
|
1203
|
+
|
|
1204
|
+
def _days(value: str, *, low: int, high: int, field: str) -> int:
|
|
1205
|
+
"""A duration or a month shorthand as a whole number of days, range-checked."""
|
|
1206
|
+
text = value.strip().lower()
|
|
1207
|
+
months = {"1m": 30, "3m": 90, "6m": 180, "12m": 365, "18m": 548, "24m": 730}
|
|
1208
|
+
if text in months:
|
|
1209
|
+
days = months[text]
|
|
1210
|
+
elif text.endswith("y") and text[:-1].isdigit():
|
|
1211
|
+
days = int(text[:-1]) * 365
|
|
1212
|
+
else:
|
|
1213
|
+
seconds = parse_duration(text)
|
|
1214
|
+
if seconds is None:
|
|
1215
|
+
raise UsageError(f"{value!r} is not a duration (try 90d, 6m or 1y)", field=field)
|
|
1216
|
+
days = max(1, round(seconds / 86400))
|
|
1217
|
+
if not low <= days <= high:
|
|
1218
|
+
raise UsageError(f"{days} days is outside the allowed {low}-{high}", field=field)
|
|
1219
|
+
return days
|
|
1220
|
+
|
|
1221
|
+
|
|
1222
|
+
SPEC_SESSION_SET = OperationSpec(
|
|
1223
|
+
id="account.session.set",
|
|
1224
|
+
request=SessionSetReq,
|
|
1225
|
+
response=SessionChange,
|
|
1226
|
+
impl=session_set,
|
|
1227
|
+
summary="Change per-session permissions, or the account-wide inactive-session TTL",
|
|
1228
|
+
aliases=("session.set", "sessions.set"),
|
|
1229
|
+
mutating=True,
|
|
1230
|
+
idempotent=True,
|
|
1231
|
+
rate_class="send",
|
|
1232
|
+
columns=("hash", "call_requests_disabled", "authorization_ttl_days"),
|
|
1233
|
+
example={"hash": "9021045", "call_requests_disabled": True},
|
|
1234
|
+
example_args="account session set 9021045 --calls off",
|
|
1235
|
+
covers=(
|
|
1236
|
+
"calls.session-accept-calls",
|
|
1237
|
+
"sessions.accept-calls",
|
|
1238
|
+
"sessions.accept-secret-chats",
|
|
1239
|
+
"sessions.auto-terminate-ttl",
|
|
1240
|
+
),
|
|
1241
|
+
)
|
|
1242
|
+
|
|
1243
|
+
|
|
1244
|
+
class SessionConfirmReq(Request):
|
|
1245
|
+
hash: Annotated[str, arg(0, metavar="HASH", help="The unconfirmed session.")]
|
|
1246
|
+
|
|
1247
|
+
|
|
1248
|
+
async def session_confirm(ctx: OpContext, req: SessionConfirmReq) -> SessionChange:
|
|
1249
|
+
"""Confirm an unconfirmed new login — the 'Yes, it's me' button.
|
|
1250
|
+
|
|
1251
|
+
Only meaningful inside `authorization_autoconfirm_period`; afterwards
|
|
1252
|
+
Telegram has confirmed it for you and this reports `already`.
|
|
1253
|
+
"""
|
|
1254
|
+
from telethon.tl.functions import account as fn
|
|
1255
|
+
|
|
1256
|
+
client = _auth.client(ctx)
|
|
1257
|
+
answer = await client(fn.GetAuthorizationsRequest())
|
|
1258
|
+
match = [
|
|
1259
|
+
auth
|
|
1260
|
+
for auth in getattr(answer, "authorizations", None) or []
|
|
1261
|
+
if str(getattr(auth, "hash", "")) == req.hash.strip()
|
|
1262
|
+
]
|
|
1263
|
+
if match and not getattr(match[0], "unconfirmed", False):
|
|
1264
|
+
_auth.already(ctx)
|
|
1265
|
+
return SessionChange(hash=req.hash, confirmed=True, already=True)
|
|
1266
|
+
await client(
|
|
1267
|
+
fn.ChangeAuthorizationSettingsRequest(hash=_auth.parse_hash(req.hash), confirmed=True)
|
|
1268
|
+
)
|
|
1269
|
+
return SessionChange(hash=req.hash, confirmed=True)
|
|
1270
|
+
|
|
1271
|
+
|
|
1272
|
+
SPEC_SESSION_CONFIRM = OperationSpec(
|
|
1273
|
+
id="account.session.confirm",
|
|
1274
|
+
request=SessionConfirmReq,
|
|
1275
|
+
response=SessionChange,
|
|
1276
|
+
impl=session_confirm,
|
|
1277
|
+
summary="Confirm an unconfirmed new login ('Yes, it's me')",
|
|
1278
|
+
aliases=("session.confirm", "sessions.confirm"),
|
|
1279
|
+
mutating=True,
|
|
1280
|
+
idempotent=True,
|
|
1281
|
+
rate_class="send",
|
|
1282
|
+
columns=("hash", "confirmed", "already"),
|
|
1283
|
+
example={"hash": "9021045", "confirmed": True},
|
|
1284
|
+
example_args="account session confirm 9021045",
|
|
1285
|
+
covers=("sessions.confirm-new-login",),
|
|
1286
|
+
)
|
|
1287
|
+
|
|
1288
|
+
|
|
1289
|
+
class SessionAcceptQrReq(Request):
|
|
1290
|
+
link: Annotated[
|
|
1291
|
+
str, arg(0, metavar="LINK", help="tg://login?token=… (paste it, or pipe from zbarimg).")
|
|
1292
|
+
]
|
|
1293
|
+
|
|
1294
|
+
|
|
1295
|
+
async def session_accept_qr(ctx: OpContext, req: SessionAcceptQrReq) -> SessionChange:
|
|
1296
|
+
"""Approve another device's QR login from this account.
|
|
1297
|
+
|
|
1298
|
+
A CLI has no camera, so the token is pasted (or piped from `zbarimg`).
|
|
1299
|
+
The authorization that was just created is printed back, because "I
|
|
1300
|
+
approved something" is not a useful answer to "what did I approve".
|
|
1301
|
+
"""
|
|
1302
|
+
from telethon.tl.functions import auth as fn
|
|
1303
|
+
|
|
1304
|
+
client = _auth.client(ctx)
|
|
1305
|
+
created = await client(fn.AcceptLoginTokenRequest(token=_auth.unb64(req.link)))
|
|
1306
|
+
ctx.emit("session_created", {"hash": str(getattr(created, "hash", ""))})
|
|
1307
|
+
return SessionChange(
|
|
1308
|
+
hash=str(getattr(created, "hash", "")),
|
|
1309
|
+
device_model=getattr(created, "device_model", None),
|
|
1310
|
+
app_name=getattr(created, "app_name", None),
|
|
1311
|
+
ip=getattr(created, "ip", None),
|
|
1312
|
+
country=getattr(created, "country", None),
|
|
1313
|
+
)
|
|
1314
|
+
|
|
1315
|
+
|
|
1316
|
+
SPEC_SESSION_ACCEPT_QR = OperationSpec(
|
|
1317
|
+
id="account.session.accept-qr",
|
|
1318
|
+
request=SessionAcceptQrReq,
|
|
1319
|
+
response=SessionChange,
|
|
1320
|
+
impl=session_accept_qr,
|
|
1321
|
+
summary="Approve another device's QR login from this account",
|
|
1322
|
+
aliases=("session.accept-qr", "sessions.accept-qr"),
|
|
1323
|
+
mutating=True,
|
|
1324
|
+
destructive=True,
|
|
1325
|
+
rate_class="send",
|
|
1326
|
+
columns=("hash", "device_model", "app_name", "ip", "country"),
|
|
1327
|
+
example={"hash": "9021045", "device_model": "Desktop", "app_name": "Telegram Desktop"},
|
|
1328
|
+
example_args="account session accept-qr tg://login?token=AQI",
|
|
1329
|
+
covers=("auth.qr-login-accept",),
|
|
1330
|
+
)
|
|
1331
|
+
|
|
1332
|
+
|
|
1333
|
+
# ---------------------------------------------------------------------------
|
|
1334
|
+
# account website * — Telegram Login on the web
|
|
1335
|
+
# ---------------------------------------------------------------------------
|
|
1336
|
+
|
|
1337
|
+
|
|
1338
|
+
class WebsiteListReq(Request):
|
|
1339
|
+
pass
|
|
1340
|
+
|
|
1341
|
+
|
|
1342
|
+
async def website_list(ctx: OpContext, req: WebsiteListReq) -> Page[WebSession]:
|
|
1343
|
+
"""Websites and bots you are logged into with Telegram Login.
|
|
1344
|
+
|
|
1345
|
+
A different list from Devices, and the one people forget: terminating
|
|
1346
|
+
every session does not disconnect a single website.
|
|
1347
|
+
"""
|
|
1348
|
+
from telethon.tl.functions import account as fn
|
|
1349
|
+
|
|
1350
|
+
client = _auth.client(ctx)
|
|
1351
|
+
answer = await client(fn.GetWebAuthorizationsRequest())
|
|
1352
|
+
users = {user.id: user for user in getattr(answer, "users", None) or []}
|
|
1353
|
+
items = [
|
|
1354
|
+
_auth.web_session_model(auth, users)
|
|
1355
|
+
for auth in getattr(answer, "authorizations", None) or []
|
|
1356
|
+
]
|
|
1357
|
+
return Page(items=items, has_more=False, total=len(items))
|
|
1358
|
+
|
|
1359
|
+
|
|
1360
|
+
SPEC_WEBSITE_LIST = OperationSpec(
|
|
1361
|
+
id="account.website.list",
|
|
1362
|
+
request=WebsiteListReq,
|
|
1363
|
+
response=Page[WebSession],
|
|
1364
|
+
impl=website_list,
|
|
1365
|
+
summary="Websites and bots you are logged into with Telegram Login",
|
|
1366
|
+
aliases=("websites.list", "privacy.website.list"),
|
|
1367
|
+
paginated=PageKind.LOCAL,
|
|
1368
|
+
rate_class="read",
|
|
1369
|
+
columns=("hash", "domain", "bot_username", "browser", "ip", "date_active"),
|
|
1370
|
+
headers=("Hash", "Domain", "Bot", "Browser", "IP", "Last active"),
|
|
1371
|
+
example={
|
|
1372
|
+
"items": [
|
|
1373
|
+
{
|
|
1374
|
+
"hash": "770",
|
|
1375
|
+
"domain": "example.com",
|
|
1376
|
+
"browser": "Firefox",
|
|
1377
|
+
"ip": "203.0.113.7",
|
|
1378
|
+
"date_active": "2026-09-03T09:14:07Z",
|
|
1379
|
+
}
|
|
1380
|
+
]
|
|
1381
|
+
},
|
|
1382
|
+
example_args="account website list",
|
|
1383
|
+
covers=("privacy.connected-websites", "websites.list"),
|
|
1384
|
+
tags=frozenset({"agent-safe"}),
|
|
1385
|
+
)
|
|
1386
|
+
|
|
1387
|
+
|
|
1388
|
+
class WebsiteRevokeReq(Request):
|
|
1389
|
+
hash: Annotated[
|
|
1390
|
+
tuple[str, ...],
|
|
1391
|
+
arg(0, metavar="HASH", required=False, variadic=True, help="Websites to disconnect."),
|
|
1392
|
+
] = ()
|
|
1393
|
+
every: Annotated[bool, opt("--all", help="Disconnect every website.")] = False
|
|
1394
|
+
block_bot: Annotated[bool, opt("--block-bot", help="Also block the bot behind the login.")] = (
|
|
1395
|
+
False
|
|
1396
|
+
)
|
|
1397
|
+
|
|
1398
|
+
|
|
1399
|
+
async def website_revoke(ctx: OpContext, req: WebsiteRevokeReq) -> WebSessionRevocation:
|
|
1400
|
+
"""Disconnect one website, or all of them. Irreversible either way."""
|
|
1401
|
+
from telethon.tl.functions import account as fn
|
|
1402
|
+
from telethon.tl.functions import contacts as contacts_fn
|
|
1403
|
+
|
|
1404
|
+
client = _auth.client(ctx)
|
|
1405
|
+
if req.every:
|
|
1406
|
+
await client(fn.ResetWebAuthorizationsRequest())
|
|
1407
|
+
return WebSessionRevocation(revoked=-1)
|
|
1408
|
+
if not req.hash:
|
|
1409
|
+
raise UsageError("give one or more website hashes, or --all", field="hash")
|
|
1410
|
+
|
|
1411
|
+
blocked: list[int] = []
|
|
1412
|
+
bots: dict[str, int] = {}
|
|
1413
|
+
if req.block_bot:
|
|
1414
|
+
answer = await client(fn.GetWebAuthorizationsRequest())
|
|
1415
|
+
bots = {
|
|
1416
|
+
str(getattr(auth, "hash", "")): int(getattr(auth, "bot_id", 0) or 0)
|
|
1417
|
+
for auth in getattr(answer, "authorizations", None) or []
|
|
1418
|
+
}
|
|
1419
|
+
done: list[str] = []
|
|
1420
|
+
for value in req.hash:
|
|
1421
|
+
await client(fn.ResetWebAuthorizationRequest(hash=_auth.parse_hash(value)))
|
|
1422
|
+
done.append(value)
|
|
1423
|
+
bot_id = bots.get(value.strip())
|
|
1424
|
+
if bot_id:
|
|
1425
|
+
# Through the resolver, never `client.get_input_entity`: the
|
|
1426
|
+
# access hash is per account, and the resolver is what makes the
|
|
1427
|
+
# NOT_FOUND / INDETERMINATE distinction in one place (§6.6).
|
|
1428
|
+
await client(contacts_fn.BlockRequest(id=await _send.resolve(ctx, str(bot_id))))
|
|
1429
|
+
blocked.append(bot_id)
|
|
1430
|
+
return WebSessionRevocation(revoked=len(done), hashes=done, blocked=blocked)
|
|
1431
|
+
|
|
1432
|
+
|
|
1433
|
+
SPEC_WEBSITE_REVOKE = OperationSpec(
|
|
1434
|
+
id="account.website.revoke",
|
|
1435
|
+
request=WebsiteRevokeReq,
|
|
1436
|
+
response=WebSessionRevocation,
|
|
1437
|
+
impl=website_revoke,
|
|
1438
|
+
summary="Disconnect one website, or all of them",
|
|
1439
|
+
aliases=("websites.revoke", "privacy.website.revoke"),
|
|
1440
|
+
mutating=True,
|
|
1441
|
+
destructive=True,
|
|
1442
|
+
rate_class="send",
|
|
1443
|
+
columns=("revoked", "hashes", "blocked"),
|
|
1444
|
+
example={"revoked": 1, "hashes": ["770"]},
|
|
1445
|
+
example_args="account website revoke 770",
|
|
1446
|
+
covers=("websites.disconnect", "websites.disconnect-all"),
|
|
1447
|
+
covers_partial=("privacy.connected-websites",),
|
|
1448
|
+
coverage_note="Listing them is `account website list`.",
|
|
1449
|
+
)
|
|
1450
|
+
|
|
1451
|
+
|
|
1452
|
+
# ---------------------------------------------------------------------------
|
|
1453
|
+
# account passkey * — auditable, never usable from here
|
|
1454
|
+
# ---------------------------------------------------------------------------
|
|
1455
|
+
|
|
1456
|
+
|
|
1457
|
+
class PasskeyListReq(Request):
|
|
1458
|
+
pass
|
|
1459
|
+
|
|
1460
|
+
|
|
1461
|
+
async def passkey_list(ctx: OpContext, req: PasskeyListReq) -> Page[Passkey]:
|
|
1462
|
+
"""List passkeys registered on the account.
|
|
1463
|
+
|
|
1464
|
+
Read-only, and that is not a limitation tlgr can lift: the server only
|
|
1465
|
+
accepts the relying-party id `telegram.org`, so no third-party client can
|
|
1466
|
+
ever create or use one. Auditing *what can log in* is still worth having.
|
|
1467
|
+
"""
|
|
1468
|
+
from telethon.tl.functions import account as fn
|
|
1469
|
+
|
|
1470
|
+
client = _auth.client(ctx)
|
|
1471
|
+
answer = await client(fn.GetPasskeysRequest())
|
|
1472
|
+
items = [_auth.passkey_model(raw) for raw in getattr(answer, "passkeys", None) or []]
|
|
1473
|
+
return Page(items=items, has_more=False, total=len(items))
|
|
1474
|
+
|
|
1475
|
+
|
|
1476
|
+
SPEC_PASSKEY_LIST = OperationSpec(
|
|
1477
|
+
id="account.passkey.list",
|
|
1478
|
+
request=PasskeyListReq,
|
|
1479
|
+
response=Page[Passkey],
|
|
1480
|
+
impl=passkey_list,
|
|
1481
|
+
summary="List passkeys registered on the account",
|
|
1482
|
+
aliases=("security.passkey.list",),
|
|
1483
|
+
paginated=PageKind.LOCAL,
|
|
1484
|
+
rate_class="read",
|
|
1485
|
+
columns=("id", "name", "date", "last_usage_date"),
|
|
1486
|
+
headers=("ID", "Name", "Added", "Last used"),
|
|
1487
|
+
example={"items": [{"id": "pk_1", "name": "iPhone", "date": "2026-09-03T09:14:07Z"}]},
|
|
1488
|
+
example_args="account passkey list",
|
|
1489
|
+
covers=("auth.passkey-list",),
|
|
1490
|
+
tags=frozenset({"agent-safe"}),
|
|
1491
|
+
)
|
|
1492
|
+
|
|
1493
|
+
|
|
1494
|
+
class PasskeyDeleteReq(Request):
|
|
1495
|
+
id: Annotated[str, arg(0, metavar="ID", help="The passkey to delete.")]
|
|
1496
|
+
|
|
1497
|
+
|
|
1498
|
+
async def passkey_delete(ctx: OpContext, req: PasskeyDeleteReq) -> Passkey:
|
|
1499
|
+
"""Delete a passkey. It cannot be re-created from here."""
|
|
1500
|
+
from telethon.tl.functions import account as fn
|
|
1501
|
+
|
|
1502
|
+
client = _auth.client(ctx)
|
|
1503
|
+
await client(fn.DeletePasskeyRequest(id=req.id))
|
|
1504
|
+
return Passkey(id=req.id, deleted=True)
|
|
1505
|
+
|
|
1506
|
+
|
|
1507
|
+
SPEC_PASSKEY_DELETE = OperationSpec(
|
|
1508
|
+
id="account.passkey.delete",
|
|
1509
|
+
request=PasskeyDeleteReq,
|
|
1510
|
+
response=Passkey,
|
|
1511
|
+
impl=passkey_delete,
|
|
1512
|
+
summary="Delete a passkey",
|
|
1513
|
+
aliases=("security.passkey.delete",),
|
|
1514
|
+
mutating=True,
|
|
1515
|
+
destructive=True,
|
|
1516
|
+
rate_class="send",
|
|
1517
|
+
columns=("id", "deleted"),
|
|
1518
|
+
example={"id": "pk_1", "deleted": True},
|
|
1519
|
+
example_args="account passkey delete pk_1",
|
|
1520
|
+
covers=("auth.passkey-delete",),
|
|
1521
|
+
)
|
|
1522
|
+
|
|
1523
|
+
|
|
1524
|
+
# ---------------------------------------------------------------------------
|
|
1525
|
+
# account password * — 2-step verification
|
|
1526
|
+
# ---------------------------------------------------------------------------
|
|
1527
|
+
|
|
1528
|
+
|
|
1529
|
+
class PasswordGetReq(Request):
|
|
1530
|
+
password: Annotated[str | None, _PASSWORD] = None
|
|
1531
|
+
verify: Annotated[
|
|
1532
|
+
bool, opt("--verify", help="Check the supplied password against the server.")
|
|
1533
|
+
] = False
|
|
1534
|
+
|
|
1535
|
+
|
|
1536
|
+
async def password_get(ctx: OpContext, req: PasswordGetReq) -> PasswordState:
|
|
1537
|
+
"""2-step verification status, and with the password the recovery address.
|
|
1538
|
+
|
|
1539
|
+
Nothing cryptographic is printed: `srp_B`, `srp_id`, `secure_random` and
|
|
1540
|
+
the KDF salts are live parameters of an in-flight exchange, not status,
|
|
1541
|
+
and a status command that leaks them teaches everyone to paste them into
|
|
1542
|
+
an issue tracker.
|
|
1543
|
+
"""
|
|
1544
|
+
from telethon.tl.functions import account as fn
|
|
1545
|
+
|
|
1546
|
+
client = _auth.client(ctx)
|
|
1547
|
+
state = await _auth.get_password(client)
|
|
1548
|
+
model = _auth.password_state(state)
|
|
1549
|
+
if req.verify and req.password is None:
|
|
1550
|
+
raise UsageError(
|
|
1551
|
+
"--verify needs the password: --password-env, --password-stdin or --password-file",
|
|
1552
|
+
field="password",
|
|
1553
|
+
)
|
|
1554
|
+
if req.password is None:
|
|
1555
|
+
return model
|
|
1556
|
+
try:
|
|
1557
|
+
settings = await _auth.with_password(
|
|
1558
|
+
client,
|
|
1559
|
+
lambda check: fn.GetPasswordSettingsRequest(password=check),
|
|
1560
|
+
req.password,
|
|
1561
|
+
state=state,
|
|
1562
|
+
)
|
|
1563
|
+
except Exception as exc:
|
|
1564
|
+
if req.verify and "PASSWORD_HASH_INVALID" in str(exc):
|
|
1565
|
+
model.password_ok = False
|
|
1566
|
+
return model
|
|
1567
|
+
raise
|
|
1568
|
+
model.password_ok = True
|
|
1569
|
+
model.recovery_email = getattr(settings, "email", None)
|
|
1570
|
+
return model
|
|
1571
|
+
|
|
1572
|
+
|
|
1573
|
+
SPEC_PASSWORD_GET = OperationSpec(
|
|
1574
|
+
id="account.password.get",
|
|
1575
|
+
request=PasswordGetReq,
|
|
1576
|
+
response=PasswordState,
|
|
1577
|
+
impl=password_get,
|
|
1578
|
+
summary="2-step verification status, and with the password the recovery address",
|
|
1579
|
+
description=(
|
|
1580
|
+
"`--verify` is the shared SRP path every other sensitive operation "
|
|
1581
|
+
"reuses: call with `inputCheckPasswordEmpty`, and on "
|
|
1582
|
+
"PASSWORD_HASH_INVALID ask and retry. SRP_ID_INVALID refetches "
|
|
1583
|
+
"`account.getPassword`, because an srp_id is single-use."
|
|
1584
|
+
),
|
|
1585
|
+
aliases=("security.password.get", "account.password.status"),
|
|
1586
|
+
rate_class="read",
|
|
1587
|
+
columns=("has_password", "has_recovery", "hint", "password_ok"),
|
|
1588
|
+
example={"has_password": True, "has_recovery": True, "hint": "the usual"},
|
|
1589
|
+
example_args="account password get",
|
|
1590
|
+
covers=(
|
|
1591
|
+
"password.check-remembered",
|
|
1592
|
+
"password.recovery-email-view",
|
|
1593
|
+
"password.setup-required-after-login",
|
|
1594
|
+
"password.srp-for-sensitive-actions",
|
|
1595
|
+
"password.status",
|
|
1596
|
+
),
|
|
1597
|
+
tags=frozenset({"agent-safe"}),
|
|
1598
|
+
)
|
|
1599
|
+
|
|
1600
|
+
|
|
1601
|
+
class PasswordSetReq(Request):
|
|
1602
|
+
new_password: Annotated[
|
|
1603
|
+
str | None,
|
|
1604
|
+
opt(secret=True, envvar="TLGR_2FA_NEW_PASSWORD", help="The password to set."),
|
|
1605
|
+
] = None
|
|
1606
|
+
hint: Annotated[str | None, opt("--hint", help="Password hint (visible at login).")] = None
|
|
1607
|
+
email: Annotated[
|
|
1608
|
+
str | None, opt("--email", help="Recovery email; the server then wants a code.")
|
|
1609
|
+
] = None
|
|
1610
|
+
code: Annotated[str | None, opt("--code", help="Confirmation code for --email.")] = None
|
|
1611
|
+
|
|
1612
|
+
|
|
1613
|
+
async def password_set(ctx: OpContext, req: PasswordSetReq) -> PasswordState:
|
|
1614
|
+
"""Turn on 2-step verification.
|
|
1615
|
+
|
|
1616
|
+
`v = g^x mod p` is computed from `account.getPassword().new_algo` with a
|
|
1617
|
+
fresh salt suffix (`telethon.password.compute_digest`);
|
|
1618
|
+
NEW_SALT_INVALID / NEW_SETTINGS_INVALID mean the KDF was wrong and
|
|
1619
|
+
PASSWORD_HASH_INVALID means one already exists — that is `change`.
|
|
1620
|
+
"""
|
|
1621
|
+
from telethon.tl.functions import account as fn
|
|
1622
|
+
|
|
1623
|
+
client = _auth.client(ctx)
|
|
1624
|
+
if req.code:
|
|
1625
|
+
await client(fn.ConfirmPasswordEmailRequest(code=req.code))
|
|
1626
|
+
return _auth.password_state(await _auth.get_password(client))
|
|
1627
|
+
if not req.new_password:
|
|
1628
|
+
raise UsageError(
|
|
1629
|
+
"give the new password through --new-password-env, --new-password-stdin "
|
|
1630
|
+
"or --new-password-file — never on the command line",
|
|
1631
|
+
field="new_password",
|
|
1632
|
+
)
|
|
1633
|
+
state = await _auth.get_password(client)
|
|
1634
|
+
if getattr(state, "has_password", False):
|
|
1635
|
+
raise UsageError(
|
|
1636
|
+
"this account already has a cloud password; change it with: "
|
|
1637
|
+
"tlgr account password change",
|
|
1638
|
+
field="new_password",
|
|
1639
|
+
)
|
|
1640
|
+
settings = _auth.new_password_settings(
|
|
1641
|
+
state, new_password=req.new_password, hint=req.hint or "", email=req.email
|
|
1642
|
+
)
|
|
1643
|
+
try:
|
|
1644
|
+
await client(
|
|
1645
|
+
fn.UpdatePasswordSettingsRequest(password=_auth.empty_password(), new_settings=settings)
|
|
1646
|
+
)
|
|
1647
|
+
except Exception as exc:
|
|
1648
|
+
if "EMAIL_UNCONFIRMED" not in str(exc):
|
|
1649
|
+
raise
|
|
1650
|
+
model = _auth.password_state(await _auth.get_password(client))
|
|
1651
|
+
ctx.warn(
|
|
1652
|
+
"the password is set but the recovery email is unconfirmed; "
|
|
1653
|
+
"finish with: tlgr account password set --code <code from the email>"
|
|
1654
|
+
)
|
|
1655
|
+
return model
|
|
1656
|
+
ctx.emit("password_set", {})
|
|
1657
|
+
return _auth.password_state(await _auth.get_password(client))
|
|
1658
|
+
|
|
1659
|
+
|
|
1660
|
+
SPEC_PASSWORD_SET = OperationSpec(
|
|
1661
|
+
id="account.password.set",
|
|
1662
|
+
request=PasswordSetReq,
|
|
1663
|
+
response=PasswordState,
|
|
1664
|
+
impl=password_set,
|
|
1665
|
+
summary="Turn on 2-step verification (cloud password, hint, recovery email)",
|
|
1666
|
+
aliases=("security.password.set",),
|
|
1667
|
+
mutating=True,
|
|
1668
|
+
rate_class="send",
|
|
1669
|
+
columns=("has_password", "hint", "email_unconfirmed_pattern"),
|
|
1670
|
+
example={"has_password": True, "hint": "the usual"},
|
|
1671
|
+
example_args="account password set --hint 'the usual'",
|
|
1672
|
+
covers=("password.recovery-email-set", "password.set"),
|
|
1673
|
+
)
|
|
1674
|
+
|
|
1675
|
+
|
|
1676
|
+
class PasswordChangeReq(Request):
|
|
1677
|
+
password: Annotated[str | None, _PASSWORD] = None
|
|
1678
|
+
new_password: Annotated[
|
|
1679
|
+
str | None,
|
|
1680
|
+
opt(secret=True, envvar="TLGR_2FA_NEW_PASSWORD", help="The replacement password."),
|
|
1681
|
+
] = None
|
|
1682
|
+
hint: Annotated[str | None, opt("--hint", help="New hint (can be changed on its own).")] = None
|
|
1683
|
+
keep_passport: Annotated[
|
|
1684
|
+
bool,
|
|
1685
|
+
opt(
|
|
1686
|
+
"--keep-passport",
|
|
1687
|
+
help="Acknowledge that Passport data is dropped when it cannot be re-encrypted.",
|
|
1688
|
+
),
|
|
1689
|
+
] = False
|
|
1690
|
+
|
|
1691
|
+
|
|
1692
|
+
async def password_change(ctx: OpContext, req: PasswordChangeReq) -> PasswordState:
|
|
1693
|
+
"""Change the cloud password, or just its hint.
|
|
1694
|
+
|
|
1695
|
+
Refused when the account holds Passport data unless `--keep-passport`
|
|
1696
|
+
acknowledges the loss: the Passport secure secret is encrypted under the
|
|
1697
|
+
password, and re-encrypting it needs a KDF tlgr does not implement. A
|
|
1698
|
+
change that silently destroyed a user's identity documents would be a
|
|
1699
|
+
much worse bug than a refusal.
|
|
1700
|
+
"""
|
|
1701
|
+
from telethon.tl.functions import account as fn
|
|
1702
|
+
|
|
1703
|
+
client = _auth.client(ctx)
|
|
1704
|
+
if req.password is None:
|
|
1705
|
+
raise UsageError(
|
|
1706
|
+
"changing the password needs the current one: --password-env, "
|
|
1707
|
+
"--password-stdin or --password-file",
|
|
1708
|
+
field="password",
|
|
1709
|
+
)
|
|
1710
|
+
if not req.new_password and req.hint is None:
|
|
1711
|
+
raise UsageError("nothing to change: pass a new password or --hint", field="new_password")
|
|
1712
|
+
state = await _auth.get_password(client)
|
|
1713
|
+
if getattr(state, "has_secure_values", False) and not req.keep_passport:
|
|
1714
|
+
raise UsageError(
|
|
1715
|
+
"this account stores Telegram Passport documents, which are encrypted under the "
|
|
1716
|
+
"cloud password. tlgr cannot re-encrypt them, so changing the password would "
|
|
1717
|
+
"destroy them. Delete them first (tlgr passport delete …) or pass --keep-passport "
|
|
1718
|
+
"to accept the loss.",
|
|
1719
|
+
field="keep_passport",
|
|
1720
|
+
)
|
|
1721
|
+
settings = _auth.new_password_settings(
|
|
1722
|
+
state, new_password=req.new_password, hint=req.hint if req.hint is not None else None
|
|
1723
|
+
)
|
|
1724
|
+
await _auth.with_password(
|
|
1725
|
+
client,
|
|
1726
|
+
lambda check: fn.UpdatePasswordSettingsRequest(password=check, new_settings=settings),
|
|
1727
|
+
req.password,
|
|
1728
|
+
state=state,
|
|
1729
|
+
)
|
|
1730
|
+
ctx.warn(
|
|
1731
|
+
"changing the password starts Telegram's 24-hour PASSWORD_TOO_FRESH cooldown "
|
|
1732
|
+
"on sensitive operations such as transferring channel ownership"
|
|
1733
|
+
)
|
|
1734
|
+
model = _auth.password_state(await _auth.get_password(client))
|
|
1735
|
+
model.changed = True
|
|
1736
|
+
model.sensitive_actions_eligible_at = _auth.iso(_auth.now() + timedelta(hours=24))
|
|
1737
|
+
return model
|
|
1738
|
+
|
|
1739
|
+
|
|
1740
|
+
SPEC_PASSWORD_CHANGE = OperationSpec(
|
|
1741
|
+
id="account.password.change",
|
|
1742
|
+
request=PasswordChangeReq,
|
|
1743
|
+
response=PasswordState,
|
|
1744
|
+
impl=password_change,
|
|
1745
|
+
summary="Change the cloud password and/or its hint",
|
|
1746
|
+
aliases=("security.password.change",),
|
|
1747
|
+
mutating=True,
|
|
1748
|
+
rate_class="send",
|
|
1749
|
+
columns=("changed", "hint", "sensitive_actions_eligible_at"),
|
|
1750
|
+
example={"changed": True, "has_password": True, "hint": "the new one"},
|
|
1751
|
+
example_args="account password change --hint 'the new one'",
|
|
1752
|
+
covers=("password.change", "password.hint", "password.secure-secret-reencrypt"),
|
|
1753
|
+
)
|
|
1754
|
+
|
|
1755
|
+
|
|
1756
|
+
class PasswordRemoveReq(Request):
|
|
1757
|
+
password: Annotated[str | None, _PASSWORD] = None
|
|
1758
|
+
|
|
1759
|
+
|
|
1760
|
+
async def password_remove(ctx: OpContext, req: PasswordRemoveReq) -> PasswordState:
|
|
1761
|
+
"""Turn 2-step verification off. Passport documents go with it."""
|
|
1762
|
+
from telethon.tl.functions import account as fn
|
|
1763
|
+
|
|
1764
|
+
client = _auth.client(ctx)
|
|
1765
|
+
if req.password is None:
|
|
1766
|
+
raise UsageError(
|
|
1767
|
+
"removing the password needs the current one: --password-env, "
|
|
1768
|
+
"--password-stdin or --password-file",
|
|
1769
|
+
field="password",
|
|
1770
|
+
)
|
|
1771
|
+
state = await _auth.get_password(client)
|
|
1772
|
+
if not getattr(state, "has_password", False):
|
|
1773
|
+
_auth.already(ctx)
|
|
1774
|
+
return _auth.password_state(state)
|
|
1775
|
+
if getattr(state, "has_secure_values", False):
|
|
1776
|
+
ctx.warn("this also deletes the Telegram Passport documents stored on the account")
|
|
1777
|
+
settings = _auth.new_password_settings(state)
|
|
1778
|
+
await _auth.with_password(
|
|
1779
|
+
client,
|
|
1780
|
+
lambda check: fn.UpdatePasswordSettingsRequest(password=check, new_settings=settings),
|
|
1781
|
+
req.password,
|
|
1782
|
+
state=state,
|
|
1783
|
+
)
|
|
1784
|
+
return _auth.password_state(await _auth.get_password(client))
|
|
1785
|
+
|
|
1786
|
+
|
|
1787
|
+
SPEC_PASSWORD_REMOVE = OperationSpec(
|
|
1788
|
+
id="account.password.remove",
|
|
1789
|
+
request=PasswordRemoveReq,
|
|
1790
|
+
response=PasswordState,
|
|
1791
|
+
impl=password_remove,
|
|
1792
|
+
summary="Turn off 2-step verification",
|
|
1793
|
+
aliases=("security.password.remove",),
|
|
1794
|
+
mutating=True,
|
|
1795
|
+
destructive=True,
|
|
1796
|
+
rate_class="send",
|
|
1797
|
+
columns=("has_password",),
|
|
1798
|
+
example={"has_password": False},
|
|
1799
|
+
example_args="account password remove",
|
|
1800
|
+
covers=("password.remove",),
|
|
1801
|
+
)
|
|
1802
|
+
|
|
1803
|
+
|
|
1804
|
+
class PasswordResetReq(Request):
|
|
1805
|
+
cancel: Annotated[bool, opt("--cancel", help="Decline the pending reset.")] = False
|
|
1806
|
+
|
|
1807
|
+
|
|
1808
|
+
async def password_reset(ctx: OpContext, req: PasswordResetReq) -> PasswordReset:
|
|
1809
|
+
"""Start (or cancel) the 7-day password reset for a password nobody has.
|
|
1810
|
+
|
|
1811
|
+
Only works from a session that is still logged in — which is what makes
|
|
1812
|
+
it different from `auth recover` and from `auth reset-account`: the
|
|
1813
|
+
account survives, only the password goes.
|
|
1814
|
+
"""
|
|
1815
|
+
from telethon.tl.functions import account as fn
|
|
1816
|
+
|
|
1817
|
+
client = _auth.client(ctx)
|
|
1818
|
+
if req.cancel:
|
|
1819
|
+
await client(fn.DeclinePasswordResetRequest())
|
|
1820
|
+
return PasswordReset(status="cancelled", cancelled=True)
|
|
1821
|
+
answer = await client(fn.ResetPasswordRequest())
|
|
1822
|
+
name = type(answer).__name__
|
|
1823
|
+
if name == "ResetPasswordRequestedWait":
|
|
1824
|
+
return PasswordReset(
|
|
1825
|
+
status="wait", until_date=_auth.iso(getattr(answer, "until_date", None))
|
|
1826
|
+
)
|
|
1827
|
+
if name == "ResetPasswordFailedWait":
|
|
1828
|
+
return PasswordReset(
|
|
1829
|
+
status="too_soon", retry_date=_auth.iso(getattr(answer, "retry_date", None))
|
|
1830
|
+
)
|
|
1831
|
+
return PasswordReset(status="ok")
|
|
1832
|
+
|
|
1833
|
+
|
|
1834
|
+
SPEC_PASSWORD_RESET = OperationSpec(
|
|
1835
|
+
id="account.password.reset",
|
|
1836
|
+
request=PasswordResetReq,
|
|
1837
|
+
response=PasswordReset,
|
|
1838
|
+
impl=password_reset,
|
|
1839
|
+
summary="Request a password reset without the recovery email, or cancel a pending one",
|
|
1840
|
+
aliases=("security.password.reset",),
|
|
1841
|
+
mutating=True,
|
|
1842
|
+
destructive=True,
|
|
1843
|
+
rate_class="send",
|
|
1844
|
+
columns=("status", "until_date", "retry_date"),
|
|
1845
|
+
example={"status": "wait", "until_date": "2026-09-10T09:14:07Z"},
|
|
1846
|
+
example_args="account password reset",
|
|
1847
|
+
covers=("password.reset-without-email",),
|
|
1848
|
+
)
|
|
1849
|
+
|
|
1850
|
+
|
|
1851
|
+
class PasswordTempReq(Request):
|
|
1852
|
+
password: Annotated[str | None, _PASSWORD] = None
|
|
1853
|
+
period: Annotated[str, opt("--period", metavar="DURATION", help="Validity window.")] = "1h"
|
|
1854
|
+
|
|
1855
|
+
|
|
1856
|
+
async def password_temp(ctx: OpContext, req: PasswordTempReq) -> TempPassword:
|
|
1857
|
+
"""Issue a temporary password for payment confirmations.
|
|
1858
|
+
|
|
1859
|
+
Issuing it is harmless; the payment that consumes it must be completed by
|
|
1860
|
+
a human, which is why tlgr has no command that spends one.
|
|
1861
|
+
"""
|
|
1862
|
+
from telethon.tl.functions import account as fn
|
|
1863
|
+
|
|
1864
|
+
client = _auth.client(ctx)
|
|
1865
|
+
if req.password is None:
|
|
1866
|
+
raise UsageError(
|
|
1867
|
+
"a temporary password is minted from the cloud password: use --password-env",
|
|
1868
|
+
field="password",
|
|
1869
|
+
)
|
|
1870
|
+
seconds = parse_duration(req.period)
|
|
1871
|
+
if seconds is None or not 60 <= seconds <= 3600:
|
|
1872
|
+
raise UsageError("--period must be between 1m and 1h", field="period")
|
|
1873
|
+
answer = await _auth.with_password(
|
|
1874
|
+
client,
|
|
1875
|
+
lambda check: fn.GetTmpPasswordRequest(password=check, period=int(seconds)),
|
|
1876
|
+
req.password,
|
|
1877
|
+
)
|
|
1878
|
+
raw = getattr(answer, "tmp_password", b"") or b""
|
|
1879
|
+
return TempPassword(
|
|
1880
|
+
tmp_password=_auth.b64(raw),
|
|
1881
|
+
valid_until=_auth.iso(getattr(answer, "valid_until", None)),
|
|
1882
|
+
)
|
|
1883
|
+
|
|
1884
|
+
|
|
1885
|
+
SPEC_PASSWORD_TEMP = OperationSpec(
|
|
1886
|
+
id="account.password.temp",
|
|
1887
|
+
request=PasswordTempReq,
|
|
1888
|
+
response=TempPassword,
|
|
1889
|
+
impl=password_temp,
|
|
1890
|
+
summary="Issue a temporary password for payment confirmations",
|
|
1891
|
+
aliases=("security.tmp-password", "account.password.tmp"),
|
|
1892
|
+
mutating=True,
|
|
1893
|
+
rate_class="send",
|
|
1894
|
+
columns=("valid_until",),
|
|
1895
|
+
example={"tmp_password": "3q2-7w", "valid_until": "2026-09-03T10:14:07Z"},
|
|
1896
|
+
example_args="account password temp --period 1h",
|
|
1897
|
+
covers=("password.temporary-payment-password",),
|
|
1898
|
+
tags=frozenset({"bearer-credential"}),
|
|
1899
|
+
)
|
|
1900
|
+
|
|
1901
|
+
|
|
1902
|
+
# ---------------------------------------------------------------------------
|
|
1903
|
+
# account email set / phone set
|
|
1904
|
+
# ---------------------------------------------------------------------------
|
|
1905
|
+
|
|
1906
|
+
|
|
1907
|
+
class EmailSetReq(Request):
|
|
1908
|
+
email: Annotated[
|
|
1909
|
+
str | None, arg(0, metavar="EMAIL", required=False, help="The address to write.")
|
|
1910
|
+
] = None
|
|
1911
|
+
password: Annotated[str | None, _PASSWORD] = None
|
|
1912
|
+
show: Annotated[bool, opt("--show", help="Only print the current addresses/patterns.")] = False
|
|
1913
|
+
kind: Annotated[str | None, choice("recovery", "login", help="Which email to write.")] = (
|
|
1914
|
+
"recovery"
|
|
1915
|
+
)
|
|
1916
|
+
code: Annotated[str | None, opt("--code", help="Confirmation code from the address.")] = None
|
|
1917
|
+
resend: Annotated[bool, opt("--resend", help="Resend the pending confirmation code.")] = False
|
|
1918
|
+
cancel: Annotated[bool, opt("--cancel", help="Cancel the pending email change.")] = False
|
|
1919
|
+
|
|
1920
|
+
|
|
1921
|
+
async def email_set(ctx: OpContext, req: EmailSetReq) -> RecoveryEmail:
|
|
1922
|
+
"""Show, set or change the recovery or login email, and confirm it.
|
|
1923
|
+
|
|
1924
|
+
The two kinds are different mechanisms wearing the same word: a
|
|
1925
|
+
*recovery* address is part of the cloud-password settings and needs the
|
|
1926
|
+
password, a *login* address is a second factor at the login screen and
|
|
1927
|
+
goes through `emailVerifyPurposeLoginChange`.
|
|
1928
|
+
"""
|
|
1929
|
+
from telethon.tl import types
|
|
1930
|
+
from telethon.tl.functions import account as fn
|
|
1931
|
+
|
|
1932
|
+
client = _auth.client(ctx)
|
|
1933
|
+
kind = req.kind or "recovery"
|
|
1934
|
+
|
|
1935
|
+
if req.show:
|
|
1936
|
+
state = await _auth.get_password(client)
|
|
1937
|
+
model = RecoveryEmail(
|
|
1938
|
+
kind=kind,
|
|
1939
|
+
email_pattern=(
|
|
1940
|
+
getattr(state, "login_email_pattern", None)
|
|
1941
|
+
if kind == "login"
|
|
1942
|
+
else getattr(state, "email_unconfirmed_pattern", None)
|
|
1943
|
+
),
|
|
1944
|
+
confirmed=not getattr(state, "email_unconfirmed_pattern", None),
|
|
1945
|
+
)
|
|
1946
|
+
if req.password and kind == "recovery":
|
|
1947
|
+
settings = await _auth.with_password(
|
|
1948
|
+
client,
|
|
1949
|
+
lambda check: fn.GetPasswordSettingsRequest(password=check),
|
|
1950
|
+
req.password,
|
|
1951
|
+
state=state,
|
|
1952
|
+
)
|
|
1953
|
+
model.email_pattern = getattr(settings, "email", None) or model.email_pattern
|
|
1954
|
+
return model
|
|
1955
|
+
|
|
1956
|
+
if req.cancel:
|
|
1957
|
+
await client(fn.CancelPasswordEmailRequest())
|
|
1958
|
+
return RecoveryEmail(kind=kind, cancelled=True)
|
|
1959
|
+
if req.resend:
|
|
1960
|
+
await client(fn.ResendPasswordEmailRequest())
|
|
1961
|
+
return RecoveryEmail(kind=kind, resent=True)
|
|
1962
|
+
|
|
1963
|
+
if kind == "login":
|
|
1964
|
+
purpose = types.EmailVerifyPurposeLoginChange()
|
|
1965
|
+
if req.code:
|
|
1966
|
+
verified = await client(
|
|
1967
|
+
fn.VerifyEmailRequest(
|
|
1968
|
+
purpose=purpose, verification=types.EmailVerificationCode(code=req.code)
|
|
1969
|
+
)
|
|
1970
|
+
)
|
|
1971
|
+
return RecoveryEmail(
|
|
1972
|
+
kind=kind, confirmed=True, email_pattern=getattr(verified, "email", None)
|
|
1973
|
+
)
|
|
1974
|
+
if not req.email:
|
|
1975
|
+
raise UsageError("give an email address, or --code to confirm one", field="email")
|
|
1976
|
+
sent = await client(fn.SendVerifyEmailCodeRequest(purpose=purpose, email=req.email))
|
|
1977
|
+
return RecoveryEmail(
|
|
1978
|
+
kind=kind,
|
|
1979
|
+
email_pattern=getattr(sent, "email_pattern", None),
|
|
1980
|
+
sent_code_length=getattr(sent, "length", None),
|
|
1981
|
+
)
|
|
1982
|
+
|
|
1983
|
+
if req.code:
|
|
1984
|
+
await client(fn.ConfirmPasswordEmailRequest(code=req.code))
|
|
1985
|
+
return RecoveryEmail(kind=kind, confirmed=True)
|
|
1986
|
+
if not req.email:
|
|
1987
|
+
raise UsageError("give an email address, or --show/--code/--resend/--cancel", field="email")
|
|
1988
|
+
if req.password is None:
|
|
1989
|
+
raise UsageError(
|
|
1990
|
+
"changing the recovery email needs the cloud password: --password-env",
|
|
1991
|
+
field="password",
|
|
1992
|
+
)
|
|
1993
|
+
state = await _auth.get_password(client)
|
|
1994
|
+
settings = _auth.new_password_settings(state, email=req.email)
|
|
1995
|
+
try:
|
|
1996
|
+
await _auth.with_password(
|
|
1997
|
+
client,
|
|
1998
|
+
lambda check: fn.UpdatePasswordSettingsRequest(password=check, new_settings=settings),
|
|
1999
|
+
req.password,
|
|
2000
|
+
state=state,
|
|
2001
|
+
)
|
|
2002
|
+
except Exception as exc:
|
|
2003
|
+
if "EMAIL_UNCONFIRMED" not in str(exc):
|
|
2004
|
+
raise
|
|
2005
|
+
return RecoveryEmail(kind=kind, email_pattern=req.email, confirmed=False)
|
|
2006
|
+
return RecoveryEmail(kind=kind, email_pattern=req.email, confirmed=True)
|
|
2007
|
+
|
|
2008
|
+
|
|
2009
|
+
SPEC_EMAIL_SET = OperationSpec(
|
|
2010
|
+
id="account.email.set",
|
|
2011
|
+
request=EmailSetReq,
|
|
2012
|
+
response=RecoveryEmail,
|
|
2013
|
+
impl=email_set,
|
|
2014
|
+
summary="Show, set or change the login / recovery email and confirm it",
|
|
2015
|
+
mutating=True,
|
|
2016
|
+
rate_class="send",
|
|
2017
|
+
columns=("kind", "email_pattern", "confirmed"),
|
|
2018
|
+
example={"kind": "recovery", "email_pattern": "a**@e*****e.com", "confirmed": False},
|
|
2019
|
+
example_args="account email set ada@example.com",
|
|
2020
|
+
covers=("account.verify-phone-email", "password.recovery-email-resend-cancel"),
|
|
2021
|
+
covers_partial=(
|
|
2022
|
+
"auth.login-email-change",
|
|
2023
|
+
"password.recovery-email-set",
|
|
2024
|
+
"password.recovery-email-view",
|
|
2025
|
+
),
|
|
2026
|
+
coverage_note=(
|
|
2027
|
+
"During a pending login the owner is `auth login-email set`; the "
|
|
2028
|
+
"password settings themselves are `account password set/get`."
|
|
2029
|
+
),
|
|
2030
|
+
)
|
|
2031
|
+
|
|
2032
|
+
|
|
2033
|
+
class PhoneSetReq(Request):
|
|
2034
|
+
phone: Annotated[
|
|
2035
|
+
str | None,
|
|
2036
|
+
arg(0, metavar="PHONE", required=False, help="New number; omit when submitting a code."),
|
|
2037
|
+
] = None
|
|
2038
|
+
code: Annotated[str | None, opt("--code", help="Code that arrived at the number.")] = None
|
|
2039
|
+
code_hash: Annotated[
|
|
2040
|
+
str | None,
|
|
2041
|
+
opt("--code-hash", metavar="HASH", help="Override the hash the first step returned."),
|
|
2042
|
+
] = None
|
|
2043
|
+
confirm_hash: Annotated[
|
|
2044
|
+
str | None,
|
|
2045
|
+
opt("--confirm-hash", metavar="HASH", help="Hash from a tg://confirmphone link."),
|
|
2046
|
+
] = None
|
|
2047
|
+
resend: Annotated[bool, opt("--resend", help="Resend the code.")] = False
|
|
2048
|
+
cancel: Annotated[bool, opt("--cancel", help="Cancel the pending code.")] = False
|
|
2049
|
+
|
|
2050
|
+
|
|
2051
|
+
async def phone_set(ctx: OpContext, req: PhoneSetReq) -> PhoneChange:
|
|
2052
|
+
"""Change the account's phone number, or confirm a phone-based action.
|
|
2053
|
+
|
|
2054
|
+
Two flows on one command because they are the same code-then-confirm
|
|
2055
|
+
shape. TDLib marks the change-number code as official-apps-only, so a
|
|
2056
|
+
third-party `api_id` may simply get `SEND_CODE_UNAVAILABLE`;
|
|
2057
|
+
`FRESH_CHANGE_PHONE_FORBIDDEN` means the session is younger than 24
|
|
2058
|
+
hours, and `PHONE_NUMBER_OCCUPIED` means the target number already has an
|
|
2059
|
+
account. All three are reported verbatim, because none of them is
|
|
2060
|
+
something a client can work around.
|
|
2061
|
+
"""
|
|
2062
|
+
from telethon.tl.functions import account as fn
|
|
2063
|
+
from telethon.tl.functions import auth as auth_fn
|
|
2064
|
+
|
|
2065
|
+
client = _auth.client(ctx)
|
|
2066
|
+
manager = _auth.accounts(ctx)
|
|
2067
|
+
alias = _auth.resolve_alias(ctx)
|
|
2068
|
+
stored = _phone_state(manager, alias)
|
|
2069
|
+
settings = _auth.code_settings()
|
|
2070
|
+
|
|
2071
|
+
if req.confirm_hash and not req.code:
|
|
2072
|
+
sent = await client(
|
|
2073
|
+
fn.SendConfirmPhoneCodeRequest(hash=req.confirm_hash, settings=settings)
|
|
2074
|
+
)
|
|
2075
|
+
fields = _auth.sent_code_fields(sent)
|
|
2076
|
+
_phone_state(manager, alias, {"code_hash": fields["code_hash"], "confirm": True})
|
|
2077
|
+
return PhoneChange(
|
|
2078
|
+
code_hash=fields["code_hash"], type=fields["type"], timeout=fields.get("timeout")
|
|
2079
|
+
)
|
|
2080
|
+
|
|
2081
|
+
code_hash = req.code_hash or str(stored.get("code_hash", ""))
|
|
2082
|
+
if req.cancel or req.resend:
|
|
2083
|
+
phone = req.phone or str(stored.get("phone", ""))
|
|
2084
|
+
if not phone or not code_hash:
|
|
2085
|
+
raise UsageError("no phone-number change is in progress", field="phone")
|
|
2086
|
+
if req.cancel:
|
|
2087
|
+
await client(auth_fn.CancelCodeRequest(phone_number=phone, phone_code_hash=code_hash))
|
|
2088
|
+
_phone_state(manager, alias, {})
|
|
2089
|
+
return PhoneChange(phone=_auth.masked(phone), cancelled=True)
|
|
2090
|
+
sent = await client(
|
|
2091
|
+
auth_fn.ResendCodeRequest(phone_number=phone, phone_code_hash=code_hash)
|
|
2092
|
+
)
|
|
2093
|
+
fields = _auth.sent_code_fields(sent)
|
|
2094
|
+
_phone_state(manager, alias, {"phone": phone, "code_hash": fields["code_hash"]})
|
|
2095
|
+
return PhoneChange(
|
|
2096
|
+
phone=_auth.masked(phone),
|
|
2097
|
+
code_hash=fields["code_hash"],
|
|
2098
|
+
type=fields["type"],
|
|
2099
|
+
resent=True,
|
|
2100
|
+
)
|
|
2101
|
+
|
|
2102
|
+
if req.code:
|
|
2103
|
+
if not code_hash:
|
|
2104
|
+
raise UsageError(
|
|
2105
|
+
"no code is pending: run `account phone set <new number>` first, "
|
|
2106
|
+
"or pass --code-hash",
|
|
2107
|
+
field="code_hash",
|
|
2108
|
+
)
|
|
2109
|
+
if stored.get("confirm"):
|
|
2110
|
+
await client(fn.ConfirmPhoneRequest(phone_code_hash=code_hash, phone_code=req.code))
|
|
2111
|
+
_phone_state(manager, alias, {})
|
|
2112
|
+
return PhoneChange(confirmed=True)
|
|
2113
|
+
phone = req.phone or str(stored.get("phone", ""))
|
|
2114
|
+
await client(
|
|
2115
|
+
fn.ChangePhoneRequest(
|
|
2116
|
+
phone_number=phone, phone_code_hash=code_hash, phone_code=req.code
|
|
2117
|
+
)
|
|
2118
|
+
)
|
|
2119
|
+
_phone_state(manager, alias, {})
|
|
2120
|
+
manager.update_account(alias, phone=phone)
|
|
2121
|
+
return PhoneChange(phone=_auth.masked(phone), changed=True)
|
|
2122
|
+
|
|
2123
|
+
if not req.phone:
|
|
2124
|
+
raise UsageError("give the new phone number, or --code/--confirm-hash", field="phone")
|
|
2125
|
+
sent = await client(fn.SendChangePhoneCodeRequest(phone_number=req.phone, settings=settings))
|
|
2126
|
+
fields = _auth.sent_code_fields(sent)
|
|
2127
|
+
_phone_state(manager, alias, {"phone": req.phone, "code_hash": fields["code_hash"]})
|
|
2128
|
+
return PhoneChange(
|
|
2129
|
+
phone=_auth.masked(req.phone),
|
|
2130
|
+
code_hash=fields["code_hash"],
|
|
2131
|
+
type=fields["type"],
|
|
2132
|
+
timeout=fields.get("timeout"),
|
|
2133
|
+
)
|
|
2134
|
+
|
|
2135
|
+
|
|
2136
|
+
def _phone_state(manager: Any, alias: str, write: dict[str, Any] | None = None) -> dict[str, Any]:
|
|
2137
|
+
"""Remember the `phone_code_hash` between the two halves of a change.
|
|
2138
|
+
|
|
2139
|
+
The same reason `login-state.json` exists: the second command runs in a
|
|
2140
|
+
different process, and Telegram will not accept a code without the hash
|
|
2141
|
+
that came with it.
|
|
2142
|
+
"""
|
|
2143
|
+
import json
|
|
2144
|
+
|
|
2145
|
+
path = manager.paths.account_dir(alias) / "phone-change.json"
|
|
2146
|
+
if write is not None:
|
|
2147
|
+
if write:
|
|
2148
|
+
write_private(path, json.dumps(write, sort_keys=True))
|
|
2149
|
+
else:
|
|
2150
|
+
with contextlib.suppress(OSError):
|
|
2151
|
+
path.unlink(missing_ok=True)
|
|
2152
|
+
return write
|
|
2153
|
+
if not path.exists():
|
|
2154
|
+
return {}
|
|
2155
|
+
try:
|
|
2156
|
+
loaded = json.loads(path.read_text(encoding="utf-8"))
|
|
2157
|
+
except (OSError, ValueError):
|
|
2158
|
+
return {}
|
|
2159
|
+
return loaded if isinstance(loaded, dict) else {}
|
|
2160
|
+
|
|
2161
|
+
|
|
2162
|
+
SPEC_PHONE_SET = OperationSpec(
|
|
2163
|
+
id="account.phone.set",
|
|
2164
|
+
request=PhoneSetReq,
|
|
2165
|
+
response=PhoneChange,
|
|
2166
|
+
impl=phone_set,
|
|
2167
|
+
summary="Change the account's phone number, or confirm a tg://confirmphone action",
|
|
2168
|
+
mutating=True,
|
|
2169
|
+
destructive=True,
|
|
2170
|
+
rate_class="send",
|
|
2171
|
+
columns=("phone", "code_hash", "type", "changed"),
|
|
2172
|
+
example={"phone": "989…89", "code_hash": "5f2a…", "type": "app"},
|
|
2173
|
+
example_args="account phone set +989123456789",
|
|
2174
|
+
covers=("account.cancel-deletion-confirm-phone", "account.change-phone"),
|
|
2175
|
+
)
|
|
2176
|
+
|
|
2177
|
+
|
|
2178
|
+
# ---------------------------------------------------------------------------
|
|
2179
|
+
# account ttl / device-locked / delete
|
|
2180
|
+
# ---------------------------------------------------------------------------
|
|
2181
|
+
|
|
2182
|
+
|
|
2183
|
+
class TtlGetReq(Request):
|
|
2184
|
+
pass
|
|
2185
|
+
|
|
2186
|
+
|
|
2187
|
+
async def ttl_get(ctx: OpContext, req: TtlGetReq) -> AccountTtl:
|
|
2188
|
+
"""Show the self-destruct timer: delete this account after N days away."""
|
|
2189
|
+
from telethon.tl.functions import account as fn
|
|
2190
|
+
|
|
2191
|
+
answer = await _auth.client(ctx)(fn.GetAccountTTLRequest())
|
|
2192
|
+
return AccountTtl(days=int(getattr(answer, "days", 0) or 0))
|
|
2193
|
+
|
|
2194
|
+
|
|
2195
|
+
SPEC_TTL_GET = OperationSpec(
|
|
2196
|
+
id="account.ttl.get",
|
|
2197
|
+
request=TtlGetReq,
|
|
2198
|
+
response=AccountTtl,
|
|
2199
|
+
impl=ttl_get,
|
|
2200
|
+
summary="Show the self-destruct timer (delete my account if I am away for N months)",
|
|
2201
|
+
rate_class="read",
|
|
2202
|
+
columns=("days",),
|
|
2203
|
+
example={"days": 365},
|
|
2204
|
+
example_args="account ttl get",
|
|
2205
|
+
covers=("account.self-destruct-ttl",),
|
|
2206
|
+
tags=frozenset({"agent-safe"}),
|
|
2207
|
+
)
|
|
2208
|
+
|
|
2209
|
+
|
|
2210
|
+
class TtlSetReq(Request):
|
|
2211
|
+
value: Annotated[
|
|
2212
|
+
str, arg(0, metavar="VALUE", help="30-730 days, or a preset: 1m 3m 6m 12m 18m 24m.")
|
|
2213
|
+
]
|
|
2214
|
+
|
|
2215
|
+
|
|
2216
|
+
async def ttl_set(ctx: OpContext, req: TtlSetReq) -> AccountTtl:
|
|
2217
|
+
"""Set the self-destruct timer. `TTL_DAYS_INVALID` outside 30-730 days."""
|
|
2218
|
+
from telethon.tl import types
|
|
2219
|
+
from telethon.tl.functions import account as fn
|
|
2220
|
+
|
|
2221
|
+
days = _days(req.value, low=30, high=730, field="value")
|
|
2222
|
+
await _auth.client(ctx)(fn.SetAccountTTLRequest(ttl=types.AccountDaysTTL(days=days)))
|
|
2223
|
+
return AccountTtl(days=days)
|
|
2224
|
+
|
|
2225
|
+
|
|
2226
|
+
SPEC_TTL_SET = OperationSpec(
|
|
2227
|
+
id="account.ttl.set",
|
|
2228
|
+
request=TtlSetReq,
|
|
2229
|
+
response=AccountTtl,
|
|
2230
|
+
impl=ttl_set,
|
|
2231
|
+
summary="Set the self-destruct timer (30-730 days)",
|
|
2232
|
+
mutating=True,
|
|
2233
|
+
destructive=True,
|
|
2234
|
+
idempotent=True,
|
|
2235
|
+
rate_class="send",
|
|
2236
|
+
columns=("days",),
|
|
2237
|
+
example={"days": 365},
|
|
2238
|
+
example_args="account ttl set 12m",
|
|
2239
|
+
covers=("messages-core.ttl-default-new-chats",),
|
|
2240
|
+
covers_partial=("account.self-destruct-ttl",),
|
|
2241
|
+
coverage_note="Reading it is `account ttl get`.",
|
|
2242
|
+
)
|
|
2243
|
+
|
|
2244
|
+
|
|
2245
|
+
class DeviceLockedSetReq(Request):
|
|
2246
|
+
period: Annotated[
|
|
2247
|
+
str | None,
|
|
2248
|
+
arg(0, metavar="PERIOD", required=False, help="How long the device stays locked."),
|
|
2249
|
+
] = None
|
|
2250
|
+
unlock: Annotated[bool, opt("--unlock", help="Report the device as unlocked.")] = False
|
|
2251
|
+
|
|
2252
|
+
|
|
2253
|
+
async def device_locked_set(ctx: OpContext, req: DeviceLockedSetReq) -> DeviceLock:
|
|
2254
|
+
"""Tell the server this device is locked, so push arrives without content.
|
|
2255
|
+
|
|
2256
|
+
Only meaningful for an account that also has a push token registered
|
|
2257
|
+
somewhere else — tlgr receives updates over MTProto and registers none —
|
|
2258
|
+
which is exactly the headless-box case: suppress Telegram's own push
|
|
2259
|
+
previews on the phone while a script is working the account.
|
|
2260
|
+
"""
|
|
2261
|
+
from telethon.tl.functions import account as fn
|
|
2262
|
+
|
|
2263
|
+
if req.unlock:
|
|
2264
|
+
seconds = 0
|
|
2265
|
+
elif req.period:
|
|
2266
|
+
seconds = int(parse_duration(req.period) or 0)
|
|
2267
|
+
else:
|
|
2268
|
+
raise UsageError("give a period, or --unlock", field="period")
|
|
2269
|
+
await _auth.client(ctx)(fn.UpdateDeviceLockedRequest(period=seconds))
|
|
2270
|
+
return DeviceLock(locked_for=seconds)
|
|
2271
|
+
|
|
2272
|
+
|
|
2273
|
+
SPEC_DEVICE_LOCKED_SET = OperationSpec(
|
|
2274
|
+
id="account.device-locked.set",
|
|
2275
|
+
request=DeviceLockedSetReq,
|
|
2276
|
+
response=DeviceLock,
|
|
2277
|
+
impl=device_locked_set,
|
|
2278
|
+
summary="Tell the server this device is locked so push arrives without content",
|
|
2279
|
+
mutating=True,
|
|
2280
|
+
idempotent=True,
|
|
2281
|
+
rate_class="send",
|
|
2282
|
+
columns=("locked_for",),
|
|
2283
|
+
example={"locked_for": 3600},
|
|
2284
|
+
example_args="account device-locked set 1h",
|
|
2285
|
+
covers=("auth.device-locked", "privacy.device-locked"),
|
|
2286
|
+
)
|
|
2287
|
+
|
|
2288
|
+
|
|
2289
|
+
class DeleteReq(Request):
|
|
2290
|
+
password: Annotated[str | None, _PASSWORD] = None
|
|
2291
|
+
reason: Annotated[str, opt("--reason", help="Reason string sent to the server.")] = ""
|
|
2292
|
+
confirm_phone: Annotated[
|
|
2293
|
+
str | None,
|
|
2294
|
+
opt("--confirm-phone", metavar="PHONE", help="Retype the account's number — required."),
|
|
2295
|
+
] = None
|
|
2296
|
+
|
|
2297
|
+
|
|
2298
|
+
async def delete(ctx: OpContext, req: DeleteReq) -> AccountDeletion:
|
|
2299
|
+
"""Delete this Telegram account permanently.
|
|
2300
|
+
|
|
2301
|
+
Irreversible, and gated by the number typed back as well as `--yes`. With
|
|
2302
|
+
2FA set and no password supplied the server answers `2FA_CONFIRM_WAIT_X`
|
|
2303
|
+
and schedules the deletion instead of performing it; that countdown is
|
|
2304
|
+
reported, along with how to cancel it.
|
|
2305
|
+
"""
|
|
2306
|
+
from telethon.tl.functions import account as fn
|
|
2307
|
+
|
|
2308
|
+
from tlgr.ops.auth import _digits, _wait_seconds
|
|
2309
|
+
|
|
2310
|
+
client = _auth.client(ctx)
|
|
2311
|
+
me = await client.get_me()
|
|
2312
|
+
phone = getattr(me, "phone", "") or ""
|
|
2313
|
+
if not req.confirm_phone or _digits(req.confirm_phone) != _digits(phone):
|
|
2314
|
+
raise UsageError(
|
|
2315
|
+
"pass --confirm-phone with this account's own number: deleting it is permanent",
|
|
2316
|
+
field="confirm_phone",
|
|
2317
|
+
)
|
|
2318
|
+
try:
|
|
2319
|
+
if req.password is None:
|
|
2320
|
+
await client(fn.DeleteAccountRequest(reason=req.reason, password=None))
|
|
2321
|
+
else:
|
|
2322
|
+
await _auth.with_password(
|
|
2323
|
+
client,
|
|
2324
|
+
lambda check: fn.DeleteAccountRequest(reason=req.reason, password=check),
|
|
2325
|
+
req.password,
|
|
2326
|
+
)
|
|
2327
|
+
except Exception as exc:
|
|
2328
|
+
remaining = _wait_seconds(str(exc))
|
|
2329
|
+
if remaining is None:
|
|
2330
|
+
raise
|
|
2331
|
+
return AccountDeletion(
|
|
2332
|
+
status="wait",
|
|
2333
|
+
wait_seconds=remaining,
|
|
2334
|
+
until=_auth.iso(_auth.now() + timedelta(seconds=remaining)),
|
|
2335
|
+
confirm_hint=(
|
|
2336
|
+
"the account has 2-step verification: Telegram scheduled the deletion instead. "
|
|
2337
|
+
"Cancel it with the tg://confirmphone link it sent: "
|
|
2338
|
+
"tlgr account phone set --confirm-hash <hash>"
|
|
2339
|
+
),
|
|
2340
|
+
)
|
|
2341
|
+
return AccountDeletion(deleted=True, status="deleted")
|
|
2342
|
+
|
|
2343
|
+
|
|
2344
|
+
SPEC_DELETE = OperationSpec(
|
|
2345
|
+
id="account.delete",
|
|
2346
|
+
request=DeleteReq,
|
|
2347
|
+
response=AccountDeletion,
|
|
2348
|
+
impl=delete,
|
|
2349
|
+
summary="Delete this Telegram account permanently",
|
|
2350
|
+
mutating=True,
|
|
2351
|
+
destructive=True,
|
|
2352
|
+
rate_class="send",
|
|
2353
|
+
columns=("deleted", "status", "wait_seconds"),
|
|
2354
|
+
example={"deleted": True, "status": "deleted"},
|
|
2355
|
+
example_args="account delete --confirm-phone +989123456789",
|
|
2356
|
+
covers=("account.delete",),
|
|
2357
|
+
)
|
|
2358
|
+
|
|
2359
|
+
|
|
2360
|
+
# ---------------------------------------------------------------------------
|
|
2361
|
+
# account smsjobs / suggestion / support
|
|
2362
|
+
# ---------------------------------------------------------------------------
|
|
2363
|
+
|
|
2364
|
+
|
|
2365
|
+
class SmsJobsSetReq(Request):
|
|
2366
|
+
join: Annotated[bool, opt("--join", help="Join the Peer-to-Peer Login Program.")] = False
|
|
2367
|
+
leave: Annotated[bool, opt("--leave", help="Leave it.")] = False
|
|
2368
|
+
allow_international: Annotated[
|
|
2369
|
+
bool | None,
|
|
2370
|
+
opt("--allow-international/--no-allow-international", help="Accept international jobs."),
|
|
2371
|
+
] = None
|
|
2372
|
+
|
|
2373
|
+
|
|
2374
|
+
async def smsjobs_set(ctx: OpContext, req: SmsJobsSetReq) -> SmsJobs:
|
|
2375
|
+
"""The Peer-to-Peer Login Program: status, join, leave.
|
|
2376
|
+
|
|
2377
|
+
Control-only, deliberately: fulfilling a job means sending an SMS from a
|
|
2378
|
+
real modem, so `smsjobs.getSmsJob`/`finishSmsJob` are not exposed —
|
|
2379
|
+
joining a programme a CLI cannot honour would earn the account a
|
|
2380
|
+
reputation hit for messages it never sent.
|
|
2381
|
+
"""
|
|
2382
|
+
from telethon.tl.functions import smsjobs as fn
|
|
2383
|
+
|
|
2384
|
+
client = _auth.client(ctx)
|
|
2385
|
+
if req.join and req.leave:
|
|
2386
|
+
raise UsageError("--join and --leave are opposites", field="join")
|
|
2387
|
+
if req.join:
|
|
2388
|
+
await client(fn.JoinRequest())
|
|
2389
|
+
if req.leave:
|
|
2390
|
+
await client(fn.LeaveRequest())
|
|
2391
|
+
if req.allow_international is not None:
|
|
2392
|
+
await client(fn.UpdateSettingsRequest(allow_international=req.allow_international))
|
|
2393
|
+
|
|
2394
|
+
model = SmsJobs()
|
|
2395
|
+
try:
|
|
2396
|
+
status = await client(fn.GetStatusRequest())
|
|
2397
|
+
except Exception:
|
|
2398
|
+
eligible = None
|
|
2399
|
+
with contextlib.suppress(Exception):
|
|
2400
|
+
eligible = await client(fn.IsEligibleToJoinRequest())
|
|
2401
|
+
return SmsJobs(
|
|
2402
|
+
eligible=eligible is not None,
|
|
2403
|
+
joined=False,
|
|
2404
|
+
terms_url=getattr(eligible, "terms_url", None),
|
|
2405
|
+
)
|
|
2406
|
+
return SmsJobs(
|
|
2407
|
+
eligible=True,
|
|
2408
|
+
joined=True,
|
|
2409
|
+
allow_international=bool(getattr(status, "allow_international", False)),
|
|
2410
|
+
recent_sent=getattr(status, "recent_sent", None),
|
|
2411
|
+
recent_since=_auth.iso(getattr(status, "recent_since", None)),
|
|
2412
|
+
recent_remains=getattr(status, "recent_remains", None),
|
|
2413
|
+
terms_url=getattr(status, "terms_url", None) or model.terms_url,
|
|
2414
|
+
)
|
|
2415
|
+
|
|
2416
|
+
|
|
2417
|
+
SPEC_SMSJOBS_SET = OperationSpec(
|
|
2418
|
+
id="account.smsjobs.set",
|
|
2419
|
+
request=SmsJobsSetReq,
|
|
2420
|
+
response=SmsJobs,
|
|
2421
|
+
impl=smsjobs_set,
|
|
2422
|
+
summary="Peer-to-Peer Login Program (SMS jobs): status, join, leave",
|
|
2423
|
+
mutating=True,
|
|
2424
|
+
rate_class="send",
|
|
2425
|
+
columns=("eligible", "joined", "recent_sent", "recent_remains"),
|
|
2426
|
+
example={"eligible": True, "joined": False, "terms_url": "https://telegram.org/tos/sms"},
|
|
2427
|
+
example_args="account smsjobs set",
|
|
2428
|
+
covers=("account.sms-jobs",),
|
|
2429
|
+
)
|
|
2430
|
+
|
|
2431
|
+
|
|
2432
|
+
class SuggestionListReq(Request):
|
|
2433
|
+
dismiss: Annotated[
|
|
2434
|
+
str | None, opt("--dismiss", metavar="NAME", help="Dismiss this suggestion.")
|
|
2435
|
+
] = None
|
|
2436
|
+
chat: Annotated[
|
|
2437
|
+
PeerRef | None,
|
|
2438
|
+
opt("--chat", metavar="CHAT", kind="peer", help="Dismiss a per-chat suggestion."),
|
|
2439
|
+
] = None
|
|
2440
|
+
hide_promo: Annotated[
|
|
2441
|
+
PeerRef | None,
|
|
2442
|
+
opt("--hide-promo", metavar="CHAT", kind="peer", help="Hide the promoted dialog."),
|
|
2443
|
+
] = None
|
|
2444
|
+
|
|
2445
|
+
|
|
2446
|
+
async def suggestion_list(ctx: OpContext, req: SuggestionListReq) -> Page[Suggestion]:
|
|
2447
|
+
"""Pending server suggestions and the promoted chat, and dismissing them.
|
|
2448
|
+
|
|
2449
|
+
`SETUP_LOGIN_EMAIL_NOSKIP` is reported as non-dismissible and `--dismiss`
|
|
2450
|
+
refuses it: the server means it, and pretending otherwise would make the
|
|
2451
|
+
command lie about what it did.
|
|
2452
|
+
"""
|
|
2453
|
+
from telethon.tl import types
|
|
2454
|
+
from telethon.tl.functions import help as fn
|
|
2455
|
+
|
|
2456
|
+
client = _auth.client(ctx)
|
|
2457
|
+
if req.dismiss:
|
|
2458
|
+
if req.dismiss.strip().upper().endswith("NOSKIP"):
|
|
2459
|
+
raise UsageError(
|
|
2460
|
+
f"{req.dismiss} cannot be dismissed; the server re-issues it until it is done",
|
|
2461
|
+
field="dismiss",
|
|
2462
|
+
)
|
|
2463
|
+
# A suggestion can be scoped to one chat (`PREMIUM_UPGRADE` on a
|
|
2464
|
+
# channel, say); `--chat` dismisses that one rather than the
|
|
2465
|
+
# account-wide nudge of the same name.
|
|
2466
|
+
peer = (
|
|
2467
|
+
await _send.resolve(ctx, req.chat) if req.chat is not None else types.InputPeerEmpty()
|
|
2468
|
+
)
|
|
2469
|
+
await client(fn.DismissSuggestionRequest(peer=peer, suggestion=req.dismiss))
|
|
2470
|
+
if req.hide_promo:
|
|
2471
|
+
await client(fn.HidePromoDataRequest(peer=await _send.resolve(ctx, req.hide_promo)))
|
|
2472
|
+
|
|
2473
|
+
config = await _auth.app_config(client)
|
|
2474
|
+
pending = [str(name) for name in (config.get("pending_suggestions") or [])]
|
|
2475
|
+
dismissed = {str(name) for name in (config.get("dismissed_suggestions") or [])}
|
|
2476
|
+
items = [
|
|
2477
|
+
Suggestion(
|
|
2478
|
+
suggestion=name,
|
|
2479
|
+
dismissible=not name.upper().endswith("NOSKIP"),
|
|
2480
|
+
dismissed=name == req.dismiss,
|
|
2481
|
+
)
|
|
2482
|
+
for name in pending
|
|
2483
|
+
if name not in dismissed
|
|
2484
|
+
]
|
|
2485
|
+
promo = None
|
|
2486
|
+
with contextlib.suppress(Exception):
|
|
2487
|
+
promo = await client(fn.GetPromoDataRequest())
|
|
2488
|
+
peer = getattr(promo, "peer", None)
|
|
2489
|
+
if peer is not None:
|
|
2490
|
+
from telethon import utils
|
|
2491
|
+
|
|
2492
|
+
items.append(
|
|
2493
|
+
Suggestion(
|
|
2494
|
+
suggestion="promo",
|
|
2495
|
+
dismissible=True,
|
|
2496
|
+
promo_peer=int(utils.get_peer_id(peer)),
|
|
2497
|
+
psa_type=getattr(promo, "psa_type", None),
|
|
2498
|
+
hidden=bool(req.hide_promo),
|
|
2499
|
+
)
|
|
2500
|
+
)
|
|
2501
|
+
return Page(items=items, has_more=False, total=len(items))
|
|
2502
|
+
|
|
2503
|
+
|
|
2504
|
+
SPEC_SUGGESTION_LIST = OperationSpec(
|
|
2505
|
+
id="account.suggestion.list",
|
|
2506
|
+
request=SuggestionListReq,
|
|
2507
|
+
response=Page[Suggestion],
|
|
2508
|
+
impl=suggestion_list,
|
|
2509
|
+
summary="Pending server suggestions and promoted chats, and dismissing them",
|
|
2510
|
+
mutating=True,
|
|
2511
|
+
paginated=PageKind.LOCAL,
|
|
2512
|
+
rate_class="read",
|
|
2513
|
+
columns=("suggestion", "dismissible", "promo_peer"),
|
|
2514
|
+
example={"items": [{"suggestion": "VALIDATE_PASSWORD", "dismissible": True}]},
|
|
2515
|
+
example_args="account suggestion list",
|
|
2516
|
+
covers=("account.promo-data", "auth.security-suggestions", "updates.config-suggestions"),
|
|
2517
|
+
covers_partial=("password.check-remembered",),
|
|
2518
|
+
coverage_note="Actually checking the password is `account password get --verify`.",
|
|
2519
|
+
)
|
|
2520
|
+
|
|
2521
|
+
|
|
2522
|
+
class SupportGetReq(Request):
|
|
2523
|
+
user: Annotated[
|
|
2524
|
+
PeerRef | None,
|
|
2525
|
+
arg(0, metavar="USER", required=False, kind="peer", help="Support accounts only."),
|
|
2526
|
+
] = None
|
|
2527
|
+
info: Annotated[bool, opt("--info", help="Read the support note on USER.")] = False
|
|
2528
|
+
set_note: Annotated[
|
|
2529
|
+
str | None, opt("--set", "--set-note", metavar="TEXT", help="Write the support note.")
|
|
2530
|
+
] = None
|
|
2531
|
+
|
|
2532
|
+
|
|
2533
|
+
async def support_get(ctx: OpContext, req: SupportGetReq) -> SupportInfo:
|
|
2534
|
+
"""Telegram support contact, the FAQ links, and the invite text.
|
|
2535
|
+
|
|
2536
|
+
`--info`/`--set` only work from a Telegram support account; every other
|
|
2537
|
+
account gets an error from the server, which is reported as-is rather
|
|
2538
|
+
than hidden behind a capability check tlgr cannot perform.
|
|
2539
|
+
"""
|
|
2540
|
+
from telethon.tl.functions import help as fn
|
|
2541
|
+
|
|
2542
|
+
client = _auth.client(ctx)
|
|
2543
|
+
if req.user and (req.info or req.set_note is not None):
|
|
2544
|
+
target = await _send.resolve(ctx, req.user)
|
|
2545
|
+
if req.set_note is not None:
|
|
2546
|
+
answer = await client(
|
|
2547
|
+
fn.EditUserInfoRequest(user_id=target, message=req.set_note, entities=[])
|
|
2548
|
+
)
|
|
2549
|
+
else:
|
|
2550
|
+
answer = await client(fn.GetUserInfoRequest(user_id=target))
|
|
2551
|
+
return SupportInfo(
|
|
2552
|
+
note=getattr(answer, "message", None),
|
|
2553
|
+
author=getattr(answer, "author", None),
|
|
2554
|
+
date=_auth.iso(getattr(answer, "date", None)),
|
|
2555
|
+
)
|
|
2556
|
+
|
|
2557
|
+
support = await client(fn.GetSupportRequest())
|
|
2558
|
+
name = None
|
|
2559
|
+
with contextlib.suppress(Exception):
|
|
2560
|
+
name = getattr(await client(fn.GetSupportNameRequest()), "name", None)
|
|
2561
|
+
invite = None
|
|
2562
|
+
with contextlib.suppress(Exception):
|
|
2563
|
+
invite = getattr(await client(fn.GetInviteTextRequest()), "message", None)
|
|
2564
|
+
config = await _auth.app_config(client)
|
|
2565
|
+
me = await client.get_me()
|
|
2566
|
+
user = getattr(support, "user", None)
|
|
2567
|
+
return SupportInfo(
|
|
2568
|
+
support_user=getattr(user, "id", None),
|
|
2569
|
+
support_name=name or getattr(user, "first_name", None),
|
|
2570
|
+
support_phone=getattr(support, "phone_number", None),
|
|
2571
|
+
faq_url=str(config.get("faq_url") or "https://telegram.org/faq"),
|
|
2572
|
+
privacy_url=str(config.get("privacy_url") or "https://telegram.org/privacy"),
|
|
2573
|
+
features_url=str(config.get("features_url") or "https://telegram.org/blog"),
|
|
2574
|
+
invite_text=invite,
|
|
2575
|
+
my_link=f"https://t.me/{me.username}" if getattr(me, "username", None) else None,
|
|
2576
|
+
)
|
|
2577
|
+
|
|
2578
|
+
|
|
2579
|
+
SPEC_SUPPORT_GET = OperationSpec(
|
|
2580
|
+
id="account.support.get",
|
|
2581
|
+
request=SupportGetReq,
|
|
2582
|
+
response=SupportInfo,
|
|
2583
|
+
impl=support_get,
|
|
2584
|
+
summary="Telegram support contact, FAQ/privacy links and the invite text",
|
|
2585
|
+
mutating=True,
|
|
2586
|
+
rate_class="read",
|
|
2587
|
+
columns=("support_user", "support_name", "faq_url"),
|
|
2588
|
+
example={
|
|
2589
|
+
"support_user": 333000,
|
|
2590
|
+
"support_name": "Telegram Support",
|
|
2591
|
+
"faq_url": "https://telegram.org/faq",
|
|
2592
|
+
},
|
|
2593
|
+
example_args="account support get",
|
|
2594
|
+
covers=(
|
|
2595
|
+
"account.faq-links",
|
|
2596
|
+
"account.invite-friends",
|
|
2597
|
+
"account.support-chat",
|
|
2598
|
+
"account.support-user-info",
|
|
2599
|
+
"auth.logout-alternatives",
|
|
2600
|
+
"contacts-users.user-support",
|
|
2601
|
+
"contacts-users.user-support-info",
|
|
2602
|
+
),
|
|
2603
|
+
tags=frozenset({"agent-safe"}),
|
|
2604
|
+
)
|