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/passport.py ADDED
@@ -0,0 +1,464 @@
1
+ """The `passport` group: Telegram Passport, and the line tlgr will not cross.
2
+
3
+ Passport stores identity documents — a passport scan, a driving licence, an
4
+ address — encrypted end to end under a secret derived from the cloud password.
5
+ Telethon carries the RPCs but none of the crypto: no secure-secret KDF, no
6
+ `secureCredentialsEncrypted` builder, no AES-256-CBC/SHA-512 padding scheme.
7
+
8
+ So this group is deliberately lopsided, and says so rather than pretending:
9
+
10
+ * **reading works** — what is stored (`passport list`), what a service is
11
+ asking for and which of my values would satisfy it (`passport form get`);
12
+ * **deleting works** — `account.deleteSecureValue` needs no crypto at all,
13
+ and being able to remove documents matters more than being able to add
14
+ them;
15
+ * **verifying a phone or email works** — that exchange is plain MTProto;
16
+ * **authorising a service does not.** `passport authorize` is registered so
17
+ that the command exists and explains itself, and it raises NOT_SUPPORTED.
18
+ Half-implementing it would mean sending a service *something* in the
19
+ credentials field; a service that accepted it would be reading identity
20
+ documents tlgr encrypted wrongly, and one that rejected it would leave the
21
+ user with an error nobody can debug.
22
+
23
+ Nothing here is ever automatic. Handing identity documents to a bot from a
24
+ script is exactly the operation that deserves a human in front of it.
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ import contextlib
30
+ import json
31
+ from typing import Annotated, Any
32
+
33
+ from tlgr.core.errors import NotSupportedError, UsageError
34
+ from tlgr.core.pagination import PageKind
35
+ from tlgr.models.auth import (
36
+ PassportDeletion,
37
+ PassportForm,
38
+ PassportRequirement,
39
+ PassportValue,
40
+ PassportVerification,
41
+ )
42
+ from tlgr.models.base import Request
43
+ from tlgr.models.page import Page
44
+ from tlgr.models.peer import PeerRef
45
+ from tlgr.ops import _auth, _send
46
+ from tlgr.ops._params import arg, choice, opt
47
+ from tlgr.ops._spec import OpContext, OperationSpec
48
+
49
+ __all__ = [
50
+ "SPEC_AUTHORIZE",
51
+ "SPEC_DELETE",
52
+ "SPEC_FORM_GET",
53
+ "SPEC_LIST",
54
+ "SPEC_VERIFY",
55
+ ]
56
+
57
+ #: The message every "we cannot do the crypto" path prints. One string, so a
58
+ #: reader who hits it twice recognises it as the same wall.
59
+ _NO_CRYPTO = (
60
+ "Telegram Passport values are encrypted with a secret derived from the cloud "
61
+ "password (AES-256-CBC plus a SHA-512 KDF). Telethon 1.44 implements none of "
62
+ "it, and tlgr will not ship a half-correct implementation of a format that "
63
+ "carries identity documents. Use an official client for this step."
64
+ )
65
+
66
+
67
+ def _value_model(raw: Any) -> PassportValue:
68
+ plain = getattr(raw, "plain_data", None)
69
+ return PassportValue(
70
+ type=_auth.secure_value_name(getattr(raw, "type", None)),
71
+ hash=_auth.b64(getattr(raw, "hash", b"")),
72
+ has_files=bool(getattr(raw, "files", None) or getattr(raw, "front_side", None)),
73
+ has_translation=bool(getattr(raw, "translation", None)),
74
+ plain_data=getattr(plain, "phone", None) or getattr(plain, "email", None),
75
+ )
76
+
77
+
78
+ # ---------------------------------------------------------------------------
79
+ # passport list
80
+ # ---------------------------------------------------------------------------
81
+
82
+
83
+ class ListReq(Request):
84
+ type: Annotated[
85
+ tuple[str, ...],
86
+ opt("--type", metavar="TYPE", help="Restrict to these document types (repeatable)."),
87
+ ] = ()
88
+ decrypt: Annotated[
89
+ bool, opt("--decrypt", help="Attempt to decrypt the values (needs the crypto stack).")
90
+ ] = False
91
+
92
+
93
+ async def list_values(ctx: OpContext, req: ListReq) -> Page[PassportValue]:
94
+ """List the Passport documents stored on this account, as metadata.
95
+
96
+ `phone` and `email` values are stored in the clear and come back filled;
97
+ every other type is end-to-end encrypted, so what is reported is the type,
98
+ the hash and whether it carries files. That is enough to audit what a
99
+ service could be given, which is the question worth asking from a CLI.
100
+ """
101
+ from telethon.tl.functions import account as fn
102
+
103
+ if req.decrypt:
104
+ raise NotSupportedError(_NO_CRYPTO)
105
+ client = _auth.client(ctx)
106
+ if req.type:
107
+ wanted = [_auth.secure_value_type(name) for name in req.type]
108
+ raw = await client(fn.GetSecureValueRequest(types=wanted))
109
+ else:
110
+ raw = await client(fn.GetAllSecureValuesRequest())
111
+ items = [_value_model(value) for value in raw or []]
112
+ return Page(items=items, has_more=False, total=len(items))
113
+
114
+
115
+ SPEC_LIST = OperationSpec(
116
+ id="passport.list",
117
+ request=ListReq,
118
+ response=Page[PassportValue],
119
+ impl=list_values,
120
+ summary="List the Telegram Passport documents stored on my account",
121
+ description=(
122
+ "Metadata only. The values are encrypted under a secret derived from "
123
+ "the cloud password, and tlgr does not implement that KDF — which is "
124
+ "why this feature is catalogued as partial rather than claimed."
125
+ ),
126
+ paginated=PageKind.LOCAL,
127
+ rate_class="read",
128
+ columns=("type", "has_files", "plain_data"),
129
+ headers=("Type", "Files", "Plain value"),
130
+ example={"items": [{"type": "phone", "hash": "3q2-7w", "plain_data": "+989123456789"}]},
131
+ example_args="passport list",
132
+ covers=("passport.list",),
133
+ tags=frozenset({"agent-safe"}),
134
+ )
135
+
136
+
137
+ # ---------------------------------------------------------------------------
138
+ # passport form get
139
+ # ---------------------------------------------------------------------------
140
+
141
+
142
+ class FormGetReq(Request):
143
+ bot: Annotated[
144
+ PeerRef | None,
145
+ arg(0, metavar="BOT", required=False, kind="peer", help="The service's bot."),
146
+ ] = None
147
+ scope: Annotated[
148
+ str | None, opt("--scope", metavar="JSON", kind="json", help="Scope JSON from the link.")
149
+ ] = None
150
+ public_key: Annotated[
151
+ str | None,
152
+ opt("--public-key", metavar="PATH", kind="path", help="The service's RSA key (PEM)."),
153
+ ] = None
154
+ nonce: Annotated[str | None, opt("--nonce", help="Nonce from the request.")] = None
155
+ country_language: Annotated[
156
+ str | None,
157
+ opt("--country-language", metavar="ISO2", help="Only look up a country's native-name tag."),
158
+ ] = None
159
+
160
+
161
+ async def form_get(ctx: OpContext, req: FormGetReq) -> PassportForm:
162
+ """Show what a service is asking for, and what of it I already hold.
163
+
164
+ Read-only inspection of the request: the document types demanded, whether
165
+ each needs a selfie or a translation, the privacy policy, and the values
166
+ already on the account that would satisfy it. `--country-language` is the
167
+ lookup that decides whether a country's forms want native-language
168
+ fields, which is otherwise a silent validation failure later.
169
+ """
170
+ from telethon.tl.functions import help as help_fn
171
+
172
+ client = _auth.client(ctx)
173
+
174
+ if req.country_language:
175
+ config = await client(help_fn.GetPassportConfigRequest(hash=0))
176
+ mapping: dict[str, Any] = {}
177
+ raw = getattr(getattr(config, "countries_langs", None), "data", "")
178
+ if raw:
179
+ with contextlib.suppress(ValueError):
180
+ mapping = json.loads(raw)
181
+ return PassportForm(country_language=mapping.get(req.country_language.upper()))
182
+
183
+ if not (req.bot and req.scope and req.public_key):
184
+ raise UsageError(
185
+ "reading a form needs the bot, --scope and --public-key from the service's link",
186
+ field="bot",
187
+ )
188
+ from pathlib import Path
189
+
190
+ from telethon.tl.functions import account as fn
191
+
192
+ entity = await _send.resolve(ctx, req.bot)
193
+ bot_id = int(getattr(entity, "user_id", 0) or 0)
194
+ form = await client(
195
+ fn.GetAuthorizationFormRequest(
196
+ bot_id=bot_id,
197
+ scope=req.scope,
198
+ public_key=Path(req.public_key).read_text(encoding="utf-8"),
199
+ )
200
+ )
201
+ return PassportForm(
202
+ bot=bot_id,
203
+ required_types=[
204
+ PassportRequirement(
205
+ type=_auth.secure_value_name(getattr(item, "type", None)),
206
+ native_names=bool(getattr(item, "native_names", False)),
207
+ selfie_required=bool(getattr(item, "selfie_required", False)),
208
+ translation_required=bool(getattr(item, "translation_required", False)),
209
+ )
210
+ for item in getattr(form, "required_types", None) or []
211
+ ],
212
+ privacy_policy_url=getattr(form, "privacy_policy_url", None),
213
+ values=[_value_model(value) for value in getattr(form, "values", None) or []],
214
+ errors=[str(getattr(err, "text", err)) for err in getattr(form, "errors", None) or []],
215
+ )
216
+
217
+
218
+ SPEC_FORM_GET = OperationSpec(
219
+ id="passport.form.get",
220
+ request=FormGetReq,
221
+ response=PassportForm,
222
+ impl=form_get,
223
+ summary="Show what a service is asking for through Telegram Passport",
224
+ rate_class="read",
225
+ columns=("bot", "privacy_policy_url"),
226
+ example={
227
+ "bot": 4242,
228
+ "required_types": [{"type": "passport", "selfie_required": True}],
229
+ "privacy_policy_url": "https://example.com/privacy",
230
+ },
231
+ example_args="passport form get --country-language DE",
232
+ covers=("passport.country-language",),
233
+ covers_partial=("auth.passport-authorize", "passport.authorization"),
234
+ coverage_note=(
235
+ "The request can be read in full; accepting it needs the Passport "
236
+ "secure-value crypto Telethon does not provide (see `passport authorize`)."
237
+ ),
238
+ tags=frozenset({"agent-safe"}),
239
+ )
240
+
241
+
242
+ # ---------------------------------------------------------------------------
243
+ # passport authorize — registered, and refused
244
+ # ---------------------------------------------------------------------------
245
+
246
+
247
+ class AuthorizeReq(Request):
248
+ bot: Annotated[PeerRef, arg(0, metavar="BOT", kind="peer", help="The service's bot.")]
249
+ password: Annotated[
250
+ str | None,
251
+ opt(secret=True, envvar="TLGR_2FA_PASSWORD", help="The 2FA cloud password."),
252
+ ] = None
253
+ scope: Annotated[
254
+ str | None, opt("--scope", metavar="JSON", kind="json", help="Scope JSON from the service.")
255
+ ] = None
256
+ public_key: Annotated[
257
+ str | None,
258
+ opt("--public-key", metavar="PATH", kind="path", help="The service's RSA key (PEM)."),
259
+ ] = None
260
+ values: Annotated[
261
+ str | None,
262
+ opt(
263
+ "--values", metavar="JSON", kind="json", help="Which stored value satisfies each type."
264
+ ),
265
+ ] = None
266
+
267
+
268
+ async def authorize(ctx: OpContext, req: AuthorizeReq) -> PassportForm:
269
+ """Refuse to share identity documents with a half-built crypto stack.
270
+
271
+ `account.acceptAuthorization` takes a `secureCredentialsEncrypted` blob:
272
+ the requested values, re-encrypted under the *service's* RSA key with a
273
+ secret derived from the cloud password. Telethon builds none of that, and
274
+ a wrong blob is not a failed command — it is either a service reading
275
+ documents encrypted incorrectly, or an error message that nobody outside
276
+ Telegram can diagnose.
277
+
278
+ Exit 13 (NOT_SUPPORTED), not 6: nothing refused this, it was never asked.
279
+ Read the request with `passport form get` and complete the share in an
280
+ official client.
281
+ """
282
+ raise NotSupportedError(
283
+ f"{_NO_CRYPTO} Inspect what the service wants with: tlgr passport form get {req.bot.value}"
284
+ )
285
+
286
+
287
+ SPEC_AUTHORIZE = OperationSpec(
288
+ id="passport.authorize",
289
+ request=AuthorizeReq,
290
+ response=PassportForm,
291
+ impl=authorize,
292
+ summary="Authorize a service with Telegram Passport (not supported: see the help)",
293
+ description=(
294
+ "Registered so the command exists and explains itself, and refused "
295
+ "with NOT_SUPPORTED. Sharing identity documents through a crypto "
296
+ "stack tlgr does not implement is worse than not offering it."
297
+ ),
298
+ mutating=True,
299
+ destructive=True,
300
+ rate_class="send",
301
+ columns=("bot",),
302
+ example={"bot": 4242},
303
+ example_args="passport authorize @examplebot",
304
+ covers_partial=("passport.authorization",),
305
+ coverage_note=(
306
+ "The request is readable (`passport form get`); acceptance needs the "
307
+ "Passport secure-value crypto and raises NOT_SUPPORTED."
308
+ ),
309
+ tags=frozenset({"not-supported"}),
310
+ )
311
+
312
+
313
+ # ---------------------------------------------------------------------------
314
+ # passport delete
315
+ # ---------------------------------------------------------------------------
316
+
317
+
318
+ class DeleteReq(Request):
319
+ type: Annotated[
320
+ tuple[str, ...],
321
+ arg(0, metavar="TYPE", variadic=True, help="Document types to delete."),
322
+ ]
323
+
324
+
325
+ async def delete(ctx: OpContext, req: DeleteReq) -> PassportDeletion:
326
+ """Delete stored Passport documents. Needs no crypto, and is irreversible."""
327
+ from telethon.tl.functions import account as fn
328
+
329
+ if not req.type:
330
+ raise UsageError(
331
+ f"name the document types to delete: {', '.join(sorted(_auth.SECURE_VALUE_TYPES))}",
332
+ field="type",
333
+ )
334
+ client = _auth.client(ctx)
335
+ wanted = [_auth.secure_value_type(name) for name in req.type]
336
+ await client(fn.DeleteSecureValueRequest(types=wanted))
337
+ deleted = [_auth.secure_value_name(item) for item in wanted]
338
+ ctx.emit("passport_deleted", {"types": deleted})
339
+ return PassportDeletion(deleted=deleted)
340
+
341
+
342
+ SPEC_DELETE = OperationSpec(
343
+ id="passport.delete",
344
+ request=DeleteReq,
345
+ response=PassportDeletion,
346
+ impl=delete,
347
+ summary="Delete stored Passport documents",
348
+ mutating=True,
349
+ destructive=True,
350
+ rate_class="send",
351
+ columns=("deleted",),
352
+ example={"deleted": ["passport"]},
353
+ example_args="passport delete passport",
354
+ covers=("passport.save-delete",),
355
+ coverage_note="Saving a value needs the encryption stack; deleting one does not.",
356
+ )
357
+
358
+
359
+ # ---------------------------------------------------------------------------
360
+ # passport verify
361
+ # ---------------------------------------------------------------------------
362
+
363
+
364
+ class VerifyReq(Request):
365
+ phone: Annotated[str | None, opt("--phone", help="Phone number to verify.")] = None
366
+ email: Annotated[str | None, opt("--email", help="Email address to verify.")] = None
367
+ code: Annotated[str | None, opt("--code", help="Code that arrived; omit to request one.")] = (
368
+ None
369
+ )
370
+ code_hash: Annotated[
371
+ str | None,
372
+ opt("--code-hash", metavar="HASH", help="The hash the first call returned (--phone only)."),
373
+ ] = None
374
+ purpose: Annotated[
375
+ str | None,
376
+ choice("passport", "login-setup", "login-change", help="EmailVerifyPurpose."),
377
+ ] = "passport"
378
+
379
+
380
+ async def verify(ctx: OpContext, req: VerifyReq) -> PassportVerification:
381
+ """Verify an extra phone number or email.
382
+
383
+ The code exchange is plain MTProto and works fully; only *storing* the
384
+ result as a Passport secure value needs the encryption stack, which is
385
+ why this command stops at "verified".
386
+ """
387
+ from telethon.tl import types
388
+ from telethon.tl.functions import account as fn
389
+
390
+ client = _auth.client(ctx)
391
+ if bool(req.phone) == bool(req.email):
392
+ raise UsageError("give exactly one of --phone or --email", field="phone")
393
+
394
+ if req.phone:
395
+ if not req.code:
396
+ sent = await client(
397
+ fn.SendVerifyPhoneCodeRequest(
398
+ phone_number=req.phone, settings=_auth.code_settings()
399
+ )
400
+ )
401
+ fields = _auth.sent_code_fields(sent)
402
+ return PassportVerification(
403
+ target=_auth.masked(req.phone),
404
+ sent=True,
405
+ code_length=fields.get("length"),
406
+ code_hash=fields["code_hash"],
407
+ )
408
+ if not req.code_hash:
409
+ raise UsageError(
410
+ "pass --code-hash with the value the first call returned; Telegram will not "
411
+ "accept a code without the hash that came with it",
412
+ field="code_hash",
413
+ )
414
+ await client(
415
+ fn.VerifyPhoneRequest(
416
+ phone_number=req.phone,
417
+ phone_code_hash=req.code_hash,
418
+ phone_code=req.code,
419
+ )
420
+ )
421
+ return PassportVerification(target=_auth.masked(req.phone), verified=True)
422
+
423
+ purposes = {
424
+ "passport": types.EmailVerifyPurposePassport,
425
+ "login-setup": types.EmailVerifyPurposeLoginSetup,
426
+ "login-change": types.EmailVerifyPurposeLoginChange,
427
+ }
428
+ if (req.purpose or "passport") != "passport":
429
+ raise UsageError(
430
+ "login-setup and login-change belong to the login flow: "
431
+ "use `tlgr auth login-email set` or `tlgr account email set --kind login`",
432
+ field="purpose",
433
+ )
434
+ purpose = purposes["passport"]()
435
+ if not req.code:
436
+ sent = await client(fn.SendVerifyEmailCodeRequest(purpose=purpose, email=req.email or ""))
437
+ return PassportVerification(
438
+ target=getattr(sent, "email_pattern", req.email) or "",
439
+ sent=True,
440
+ code_length=getattr(sent, "length", None),
441
+ )
442
+ verified = await client(
443
+ fn.VerifyEmailRequest(
444
+ purpose=purpose, verification=types.EmailVerificationCode(code=req.code)
445
+ )
446
+ )
447
+ return PassportVerification(target=getattr(verified, "email", req.email) or "", verified=True)
448
+
449
+
450
+ SPEC_VERIFY = OperationSpec(
451
+ id="passport.verify",
452
+ request=VerifyReq,
453
+ response=PassportVerification,
454
+ impl=verify,
455
+ summary="Verify an extra phone number or email",
456
+ mutating=True,
457
+ rate_class="send",
458
+ columns=("target", "sent", "verified"),
459
+ example={"target": "a**@e*****e.com", "sent": True, "code_length": 6},
460
+ example_args="passport verify --email ada@example.com",
461
+ covers=("auth.passport-verify-phone-email",),
462
+ covers_partial=("account.verify-phone-email",),
463
+ coverage_note="The recovery/login addresses are owned by `account email set`.",
464
+ )