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