tlgr-cli 2.0.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (192) hide show
  1. tlgr/__init__.py +3 -0
  2. tlgr/__main__.py +6 -0
  3. tlgr/actions/__init__.py +45 -0
  4. tlgr/actions/forward.py +74 -0
  5. tlgr/actions/reply.py +32 -0
  6. tlgr/cli/__init__.py +259 -0
  7. tlgr/cli/confirm.py +55 -0
  8. tlgr/cli/errors.py +84 -0
  9. tlgr/cli/gen.py +690 -0
  10. tlgr/cli/globals.py +273 -0
  11. tlgr/cli/introspect.py +170 -0
  12. tlgr/cli/params.py +189 -0
  13. tlgr/cli/render.py +418 -0
  14. tlgr/core/__init__.py +0 -0
  15. tlgr/core/accounts.py +384 -0
  16. tlgr/core/config.py +358 -0
  17. tlgr/core/custom_tl.py +170 -0
  18. tlgr/core/errors.py +687 -0
  19. tlgr/core/eventtypes.py +1170 -0
  20. tlgr/core/identity.py +127 -0
  21. tlgr/core/launchd.py +122 -0
  22. tlgr/core/logging.py +194 -0
  23. tlgr/core/media.py +134 -0
  24. tlgr/core/output.py +251 -0
  25. tlgr/core/pagination.py +227 -0
  26. tlgr/core/paths.py +360 -0
  27. tlgr/core/peers.py +427 -0
  28. tlgr/core/process.py +138 -0
  29. tlgr/core/signing.py +38 -0
  30. tlgr/core/systemd.py +96 -0
  31. tlgr/core/telethon_compat.py +295 -0
  32. tlgr/core/text.py +211 -0
  33. tlgr/core/timefmt.py +199 -0
  34. tlgr/core/tl.py +98 -0
  35. tlgr/daemon/__init__.py +0 -0
  36. tlgr/daemon/app.py +869 -0
  37. tlgr/daemon/dispatch.py +446 -0
  38. tlgr/daemon/events.py +723 -0
  39. tlgr/daemon/files.py +431 -0
  40. tlgr/daemon/idle.py +119 -0
  41. tlgr/daemon/jobs.py +68 -0
  42. tlgr/daemon/main.py +161 -0
  43. tlgr/daemon/peercred.py +75 -0
  44. tlgr/daemon/policy.py +113 -0
  45. tlgr/daemon/preauth.py +366 -0
  46. tlgr/daemon/ratelimit.py +391 -0
  47. tlgr/daemon/server.py +24 -0
  48. tlgr/daemon/session.py +648 -0
  49. tlgr/daemon/sessions.py +274 -0
  50. tlgr/daemon/singleton.py +114 -0
  51. tlgr/daemon/stream.py +193 -0
  52. tlgr/daemon/transfers.py +219 -0
  53. tlgr/daemon/webhook.py +390 -0
  54. tlgr/data/catalog_index.json +1 -0
  55. tlgr/data/parity_waivers.toml +90 -0
  56. tlgr/filters/__init__.py +42 -0
  57. tlgr/filters/compose.py +121 -0
  58. tlgr/filters/content.py +85 -0
  59. tlgr/filters/context.py +114 -0
  60. tlgr/filters/message.py +161 -0
  61. tlgr/filters/temporal.py +87 -0
  62. tlgr/filters/user.py +36 -0
  63. tlgr/gateway/__init__.py +1 -0
  64. tlgr/gateway/config.py +161 -0
  65. tlgr/gateway/engine.py +215 -0
  66. tlgr/gateway/event.py +22 -0
  67. tlgr/jobs/__init__.py +0 -0
  68. tlgr/jobs/base.py +81 -0
  69. tlgr/jobs/client.py +37 -0
  70. tlgr/models/__init__.py +1220 -0
  71. tlgr/models/admin.py +744 -0
  72. tlgr/models/auth.py +510 -0
  73. tlgr/models/base.py +81 -0
  74. tlgr/models/bot.py +576 -0
  75. tlgr/models/business.py +265 -0
  76. tlgr/models/call.py +586 -0
  77. tlgr/models/config.py +101 -0
  78. tlgr/models/contact.py +481 -0
  79. tlgr/models/daemon.py +336 -0
  80. tlgr/models/dialog.py +626 -0
  81. tlgr/models/envelope.py +68 -0
  82. tlgr/models/error.py +30 -0
  83. tlgr/models/event.py +79 -0
  84. tlgr/models/export.py +66 -0
  85. tlgr/models/gift.py +275 -0
  86. tlgr/models/inline.py +84 -0
  87. tlgr/models/location.py +115 -0
  88. tlgr/models/media.py +507 -0
  89. tlgr/models/message.py +584 -0
  90. tlgr/models/net.py +232 -0
  91. tlgr/models/notify.py +105 -0
  92. tlgr/models/page.py +32 -0
  93. tlgr/models/payment.py +172 -0
  94. tlgr/models/peer.py +400 -0
  95. tlgr/models/poll.py +119 -0
  96. tlgr/models/premium.py +161 -0
  97. tlgr/models/privacy.py +93 -0
  98. tlgr/models/profile.py +217 -0
  99. tlgr/models/reaction.py +160 -0
  100. tlgr/models/resolve.py +175 -0
  101. tlgr/models/settings.py +103 -0
  102. tlgr/models/stars.py +101 -0
  103. tlgr/models/sticker.py +243 -0
  104. tlgr/models/story.py +467 -0
  105. tlgr/models/sync.py +105 -0
  106. tlgr/models/todo.py +36 -0
  107. tlgr/models/webapp.py +89 -0
  108. tlgr/ops/__init__.py +63 -0
  109. tlgr/ops/_admin.py +313 -0
  110. tlgr/ops/_auth.py +599 -0
  111. tlgr/ops/_bots.py +586 -0
  112. tlgr/ops/_calls.py +535 -0
  113. tlgr/ops/_common.py +160 -0
  114. tlgr/ops/_layer.py +46 -0
  115. tlgr/ops/_media.py +592 -0
  116. tlgr/ops/_params.py +212 -0
  117. tlgr/ops/_rights.py +402 -0
  118. tlgr/ops/_send.py +593 -0
  119. tlgr/ops/_serialize.py +667 -0
  120. tlgr/ops/_settings.py +306 -0
  121. tlgr/ops/_spec.py +167 -0
  122. tlgr/ops/_story.py +743 -0
  123. tlgr/ops/account.py +2604 -0
  124. tlgr/ops/agent.py +937 -0
  125. tlgr/ops/auth.py +1282 -0
  126. tlgr/ops/bot.py +4880 -0
  127. tlgr/ops/business.py +1520 -0
  128. tlgr/ops/call.py +1610 -0
  129. tlgr/ops/chat.py +4025 -0
  130. tlgr/ops/chat_admin.py +929 -0
  131. tlgr/ops/chat_extra.py +1061 -0
  132. tlgr/ops/chat_invite.py +716 -0
  133. tlgr/ops/chat_manage.py +1691 -0
  134. tlgr/ops/chat_member.py +1357 -0
  135. tlgr/ops/chat_stats.py +902 -0
  136. tlgr/ops/chat_topic.py +905 -0
  137. tlgr/ops/conference.py +791 -0
  138. tlgr/ops/config.py +1698 -0
  139. tlgr/ops/contact.py +2330 -0
  140. tlgr/ops/daemon.py +1397 -0
  141. tlgr/ops/draft.py +299 -0
  142. tlgr/ops/emoji.py +343 -0
  143. tlgr/ops/events.py +1327 -0
  144. tlgr/ops/export.py +596 -0
  145. tlgr/ops/folder.py +1322 -0
  146. tlgr/ops/gif.py +522 -0
  147. tlgr/ops/gift.py +1546 -0
  148. tlgr/ops/giveaway.py +541 -0
  149. tlgr/ops/inline.py +773 -0
  150. tlgr/ops/job.py +799 -0
  151. tlgr/ops/location.py +917 -0
  152. tlgr/ops/media.py +4495 -0
  153. tlgr/ops/message.py +3769 -0
  154. tlgr/ops/net.py +536 -0
  155. tlgr/ops/notify.py +840 -0
  156. tlgr/ops/passport.py +464 -0
  157. tlgr/ops/payment.py +907 -0
  158. tlgr/ops/poll.py +1078 -0
  159. tlgr/ops/premium.py +488 -0
  160. tlgr/ops/privacy.py +794 -0
  161. tlgr/ops/profile.py +1481 -0
  162. tlgr/ops/proxy.py +750 -0
  163. tlgr/ops/reaction.py +1475 -0
  164. tlgr/ops/resolve.py +1140 -0
  165. tlgr/ops/search.py +521 -0
  166. tlgr/ops/settings.py +1066 -0
  167. tlgr/ops/stars.py +594 -0
  168. tlgr/ops/sticker.py +1602 -0
  169. tlgr/ops/story.py +3216 -0
  170. tlgr/ops/sync.py +788 -0
  171. tlgr/ops/todo.py +514 -0
  172. tlgr/ops/user.py +1406 -0
  173. tlgr/ops/vc.py +2351 -0
  174. tlgr/ops/webapp.py +717 -0
  175. tlgr/ops/webhook.py +418 -0
  176. tlgr/parity.py +386 -0
  177. tlgr/processors/__init__.py +125 -0
  178. tlgr/processors/regex.py +26 -0
  179. tlgr/processors/text.py +56 -0
  180. tlgr/registry.py +519 -0
  181. tlgr/schema.py +173 -0
  182. tlgr/transport/__init__.py +30 -0
  183. tlgr/transport/autostart.py +293 -0
  184. tlgr/transport/client.py +805 -0
  185. tlgr/transport/ndjson.py +44 -0
  186. tlgr/version.py +31 -0
  187. tlgr_cli-2.0.1.dist-info/METADATA +957 -0
  188. tlgr_cli-2.0.1.dist-info/RECORD +192 -0
  189. tlgr_cli-2.0.1.dist-info/WHEEL +5 -0
  190. tlgr_cli-2.0.1.dist-info/entry_points.txt +2 -0
  191. tlgr_cli-2.0.1.dist-info/licenses/LICENSE +21 -0
  192. tlgr_cli-2.0.1.dist-info/top_level.txt +1 -0
tlgr/ops/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
+ )