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,599 @@
1
+ """Plumbing shared by the `auth`, `account` and `passport` groups.
2
+
3
+ Four things live here because they are the same in fifteen places and getting
4
+ any of them wrong once is a security bug rather than a formatting one.
5
+
6
+ * **SRP.** Every `InputCheckPasswordSRP` method follows one shape: call with
7
+ `inputCheckPasswordEmpty`, and when the server answers
8
+ `PASSWORD_HASH_INVALID` ask for the password and retry. `SRP_ID_INVALID`
9
+ means the challenge went stale and `account.getPassword` has to be
10
+ refetched — a retry with the same `srp_id` fails forever. That loop is
11
+ written once, in `with_password()`.
12
+ * **Code types.** `auth.sendCode` answers with one of eleven `sentCodeType*`
13
+ constructors and each one asks the human to look somewhere different. The
14
+ mapping to a stable lowercase name is here so that `auth send-code`,
15
+ `auth resend-code` and `account phone set` all report it identically.
16
+ * **Secrets never widen.** A cloud password arrives from env/stdin/file and
17
+ reaches exactly one call. Nothing here logs it, stores it or puts it in a
18
+ model — and `PasswordState` has no field it could land in.
19
+ * **The daemon owns the session file.** A login opens a *pre-auth* client
20
+ through `daemon.preauth`, never a second `TelegramClient` on a file the
21
+ daemon may already hold: two live connections on one auth key is
22
+ `AUTH_KEY_DUPLICATED`, and Telegram revokes the session rather than
23
+ refusing the second one.
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ import base64
29
+ import re
30
+ from datetime import datetime, timedelta, timezone
31
+ from typing import Any
32
+
33
+ from tlgr.core.errors import (
34
+ AuthPasswordRequiredError,
35
+ DaemonError,
36
+ UsageError,
37
+ )
38
+ from tlgr.core.timefmt import fmt_dt, to_unix
39
+ from tlgr.models.auth import Passkey, PasswordState, Session, WebSession
40
+
41
+ __all__ = [
42
+ "SECURE_VALUE_TYPES",
43
+ "accounts",
44
+ "app_config",
45
+ "client",
46
+ "code_settings",
47
+ "code_type",
48
+ "emit",
49
+ "empty_password",
50
+ "get_password",
51
+ "iso",
52
+ "passkey_model",
53
+ "password_state",
54
+ "preauth",
55
+ "resolve_alias",
56
+ "secure_value_type",
57
+ "session_model",
58
+ "sessions",
59
+ "web_session_model",
60
+ "with_password",
61
+ ]
62
+
63
+ #: The URL-safe base64 alphabet, and nothing else.
64
+ _B64_RE = re.compile(r"[A-Za-z0-9_-]+")
65
+
66
+ #: 24 hours. `SESSION_TOO_FRESH_X` and `PASSWORD_TOO_FRESH_X` both count to
67
+ #: this, and neither error says so.
68
+ FRESHNESS_WINDOW = timedelta(hours=24)
69
+
70
+ #: The default Telegram uses when `help.getAppConfig` carries no
71
+ #: `authorization_autoconfirm_period`.
72
+ DEFAULT_AUTOCONFIRM_PERIOD = 604800
73
+
74
+
75
+ # ---------------------------------------------------------------------------
76
+ # Context accessors
77
+ # ---------------------------------------------------------------------------
78
+
79
+
80
+ def client(ctx: Any) -> Any:
81
+ """The connected Telethon client, or a daemon error saying why there is none."""
82
+ found = getattr(ctx, "client", None)
83
+ if found is None: # pragma: no cover - the daemon always supplies one
84
+ raise DaemonError("this operation needs a connected account")
85
+ return found
86
+
87
+
88
+ def accounts(ctx: Any) -> Any:
89
+ """The alias registry, rooted where this process's tlgr home is.
90
+
91
+ Built from `ctx.paths` in the daemon and from `$TLGR_HOME` in the CLI, so
92
+ a local operation and a daemon operation read the same `accounts.json`.
93
+ """
94
+ from tlgr.core.accounts import AccountManager
95
+
96
+ base = getattr(getattr(ctx, "paths", None), "base", None)
97
+ return AccountManager(base)
98
+
99
+
100
+ def sessions(ctx: Any) -> Any:
101
+ """The daemon's session manager — the only thing that opens a session file.
102
+
103
+ Reached through the context rather than imported, because `ops/` may not
104
+ import `daemon/` (§2.2). An operation that needs it and does not have it
105
+ is being run outside the daemon, which is a daemon error, not a crash.
106
+ """
107
+ manager = getattr(getattr(ctx, "daemon", None), "sessions", None)
108
+ if manager is None:
109
+ raise DaemonError(
110
+ "this operation manages a session file, which only the daemon may open. "
111
+ "Start it with: tlgr daemon start"
112
+ )
113
+ return manager
114
+
115
+
116
+ def preauth(ctx: Any) -> Any:
117
+ """The daemon's pre-auth service — the only thing allowed to open a session."""
118
+ service = getattr(getattr(ctx, "daemon", None), "preauth", None)
119
+ if service is None:
120
+ raise DaemonError(
121
+ "logging in runs in the daemon, which owns the session file. "
122
+ "Start it with: tlgr daemon start"
123
+ )
124
+ return service
125
+
126
+
127
+ def resolve_alias(ctx: Any, explicit: str | None = None) -> str:
128
+ """Positional alias > `-a/--account` > the active account.
129
+
130
+ An operation that names an account in its request must honour that name
131
+ first: `tlgr account logout work` has to log *work* out even when the
132
+ active account is something else.
133
+ """
134
+ from tlgr.core.errors import AccountRequiredError
135
+
136
+ if explicit:
137
+ from tlgr.core.paths import validate_alias
138
+
139
+ return validate_alias(explicit)
140
+ on_context = (getattr(ctx, "account", "") or "").strip()
141
+ if on_context:
142
+ return on_context
143
+ active = accounts(ctx).get_active()
144
+ if not active:
145
+ raise AccountRequiredError("no account was given and none is active")
146
+ return str(active)
147
+
148
+
149
+ def masked(phone: str) -> str:
150
+ """`+989123456789` → `989…89`. A phone number is an identifier, not a label."""
151
+ digits = "".join(character for character in phone if character.isdigit())
152
+ if len(digits) < 6:
153
+ return "***"
154
+ return f"{digits[:3]}…{digits[-2:]}"
155
+
156
+
157
+ def iso(value: Any) -> str | None:
158
+ """Any datetime-ish thing as RFC-3339 UTC."""
159
+ return fmt_dt(value) if isinstance(value, datetime) else None
160
+
161
+
162
+ # ---------------------------------------------------------------------------
163
+ # Login codes
164
+ # ---------------------------------------------------------------------------
165
+
166
+ #: `sentCodeTypeX` → the name tlgr prints. Stable: an agent branches on it.
167
+ _CODE_TYPES = {
168
+ "SentCodeTypeApp": "app",
169
+ "SentCodeTypeSms": "sms",
170
+ "SentCodeTypeSmsWord": "sms_word",
171
+ "SentCodeTypeSmsPhrase": "sms_phrase",
172
+ "SentCodeTypeCall": "call",
173
+ "SentCodeTypeFlashCall": "flash_call",
174
+ "SentCodeTypeMissedCall": "missed_call",
175
+ "SentCodeTypeFragmentSms": "fragment",
176
+ "SentCodeTypeEmailCode": "email",
177
+ "SentCodeTypeSetUpEmailRequired": "setup_email_required",
178
+ "SentCodeTypeFirebaseSms": "firebase",
179
+ "CodeTypeSms": "sms",
180
+ "CodeTypeCall": "call",
181
+ "CodeTypeFlashCall": "flash_call",
182
+ "CodeTypeMissedCall": "missed_call",
183
+ "CodeTypeFragmentSms": "fragment",
184
+ }
185
+
186
+
187
+ def code_type(value: Any) -> str:
188
+ """The stable lowercase name of a `sentCodeType*` / `codeType*` object."""
189
+ if value is None:
190
+ return ""
191
+ name = type(value).__name__
192
+ return _CODE_TYPES.get(name, name.removeprefix("SentCodeType").removeprefix("CodeType").lower())
193
+
194
+
195
+ def code_settings(
196
+ *,
197
+ current_number: bool = False,
198
+ allow_flashcall: bool = False,
199
+ allow_missed_call: bool = False,
200
+ logout_tokens: list[bytes] | None = None,
201
+ ) -> Any:
202
+ """`codeSettings`, with `allow_app_hash` always off.
203
+
204
+ `allow_app_hash` promises the server that the client can read the SMS
205
+ itself; a CLI cannot, and claiming otherwise makes Telegram choose a
206
+ delivery route nobody will ever see.
207
+ """
208
+ from telethon.tl import types
209
+
210
+ return types.CodeSettings(
211
+ current_number=current_number or None,
212
+ allow_flashcall=allow_flashcall or None,
213
+ allow_missed_call=allow_missed_call or None,
214
+ logout_tokens=list(logout_tokens) if logout_tokens else None,
215
+ )
216
+
217
+
218
+ def sent_code_fields(sent: Any) -> dict[str, Any]:
219
+ """The type-specific half of a `auth.sentCode`, flattened for the model."""
220
+ kind = getattr(sent, "type", None)
221
+ fields: dict[str, Any] = {
222
+ "type": code_type(kind),
223
+ "next_type": code_type(getattr(sent, "next_type", None)) or None,
224
+ "timeout": getattr(sent, "timeout", None),
225
+ "code_hash": getattr(sent, "phone_code_hash", "") or "",
226
+ }
227
+ for name, target in (
228
+ ("length", "length"),
229
+ ("url", "fragment_url"),
230
+ ("email_pattern", "email_pattern"),
231
+ ("beginning", "beginning"),
232
+ ("reset_available_period", "reset_available_period"),
233
+ ):
234
+ value = getattr(kind, name, None)
235
+ if value is not None:
236
+ fields[target] = value
237
+ fields["google_signin_allowed"] = bool(getattr(kind, "google_signin_allowed", False))
238
+ fields["apple_signin_allowed"] = bool(getattr(kind, "apple_signin_allowed", False))
239
+ pending = getattr(kind, "reset_pending_date", None)
240
+ if pending is not None:
241
+ fields["reset_pending_date"] = iso(pending)
242
+ return fields
243
+
244
+
245
+ # ---------------------------------------------------------------------------
246
+ # The cloud password (SRP)
247
+ # ---------------------------------------------------------------------------
248
+
249
+
250
+ async def get_password(caller: Any) -> Any:
251
+ """`account.getPassword` — the challenge every SRP call is built from."""
252
+ from telethon.tl.functions import account as fn
253
+
254
+ return await caller(fn.GetPasswordRequest())
255
+
256
+
257
+ def empty_password() -> Any:
258
+ """`inputCheckPasswordEmpty`: "I have no password", not "I forgot it"."""
259
+ from telethon.tl import types
260
+
261
+ return types.InputCheckPasswordEmpty()
262
+
263
+
264
+ def _srp(state: Any, secret: str | None) -> Any:
265
+ if secret is None:
266
+ return empty_password()
267
+ from telethon import password as srp_module
268
+
269
+ return srp_module.compute_check(state, secret)
270
+
271
+
272
+ async def with_password(caller: Any, build: Any, secret: str | None, *, state: Any = None) -> Any:
273
+ """Run an `InputCheckPasswordSRP` request, refetching the challenge once.
274
+
275
+ *build* takes the computed check object and returns the request. The
276
+ retry exists because `srp_id` is single-use: a second attempt with a
277
+ stale one answers `SRP_ID_INVALID` forever, and the only cure is a fresh
278
+ `account.getPassword`.
279
+ """
280
+ current = state if state is not None else await get_password(caller)
281
+ try:
282
+ return await caller(build(_srp(current, secret)))
283
+ except Exception as exc:
284
+ message = str(exc)
285
+ if "SRP_ID_INVALID" in message or "SRP_PASSWORD_CHANGED" in message:
286
+ refreshed = await get_password(caller)
287
+ return await caller(build(_srp(refreshed, secret)))
288
+ if "PASSWORD_HASH_INVALID" in message and secret is None:
289
+ raise AuthPasswordRequiredError(
290
+ "this account has a cloud password; supply it with "
291
+ "--password-env TLGR_2FA_PASSWORD (or --password-stdin/--password-file)"
292
+ ) from exc
293
+ raise
294
+
295
+
296
+ def new_password_settings(
297
+ state: Any,
298
+ *,
299
+ new_password: str | None = None,
300
+ hint: str | None = None,
301
+ email: str | None = None,
302
+ ) -> Any:
303
+ """`passwordInputSettings` for set / change / remove.
304
+
305
+ Removing is the same call with an empty `new_password_hash`, which is why
306
+ there is no separate builder for it.
307
+ """
308
+ from telethon import password as srp_module
309
+ from telethon.tl.types import account as account_types
310
+
311
+ if new_password:
312
+ digest = srp_module.compute_digest(state.new_algo, new_password)
313
+ return account_types.PasswordInputSettings(
314
+ new_algo=state.new_algo,
315
+ new_password_hash=digest,
316
+ hint=hint or "",
317
+ email=email or None,
318
+ )
319
+ if hint is not None and new_password is None and email is None:
320
+ return account_types.PasswordInputSettings(hint=hint)
321
+ if email is not None and new_password is None:
322
+ return account_types.PasswordInputSettings(email=email)
323
+ # No new password: turn 2FA off.
324
+ return account_types.PasswordInputSettings(
325
+ new_algo=None, new_password_hash=b"", hint="", email=None
326
+ )
327
+
328
+
329
+ def password_state(state: Any, settings: Any = None) -> PasswordState:
330
+ """`account.Password` as the model, without a byte of SRP material."""
331
+ created = getattr(state, "pending_reset_date", None)
332
+ return PasswordState(
333
+ has_password=bool(getattr(state, "has_password", False)),
334
+ has_recovery=bool(getattr(state, "has_recovery", False)),
335
+ has_secure_values=bool(getattr(state, "has_secure_values", False)),
336
+ hint=getattr(state, "hint", None) or None,
337
+ email_unconfirmed_pattern=getattr(state, "email_unconfirmed_pattern", None) or None,
338
+ login_email_pattern=getattr(state, "login_email_pattern", None) or None,
339
+ pending_reset_date=iso(created),
340
+ recovery_email=getattr(settings, "email", None) if settings is not None else None,
341
+ )
342
+
343
+
344
+ # ---------------------------------------------------------------------------
345
+ # Sessions
346
+ # ---------------------------------------------------------------------------
347
+
348
+
349
+ async def app_config(caller: Any) -> dict[str, Any]:
350
+ """`help.getAppConfig` as a plain dict, or `{}` when it cannot be read.
351
+
352
+ Empty rather than raising: every caller uses it for a *derived* field
353
+ (the auto-confirm deadline, a freeze appeal URL), and losing a derived
354
+ field is not worth failing the command the user actually asked for.
355
+ """
356
+ from telethon.tl.functions import help as fn
357
+
358
+ try:
359
+ answer = await caller(fn.GetAppConfigRequest(hash=0))
360
+ except Exception:
361
+ return {}
362
+ return _json_value(getattr(answer, "config", None)) or {}
363
+
364
+
365
+ def _json_value(node: Any) -> Any:
366
+ """A `JSONValue` tree as builtins."""
367
+ name = type(node).__name__
368
+ if name == "JsonObject":
369
+ return {item.key: _json_value(item.value) for item in node.value}
370
+ if name == "JsonArray":
371
+ return [_json_value(item) for item in node.value]
372
+ if name in ("JsonString", "JsonNumber", "JsonBool"):
373
+ return node.value
374
+ if name == "JsonNull":
375
+ return None
376
+ return None
377
+
378
+
379
+ def session_model(
380
+ auth: Any, *, ttl_days: int | None = None, autoconfirm_period: int = 0
381
+ ) -> Session:
382
+ """One `authorization` as a row, with the two deadlines nobody can guess.
383
+
384
+ `deny_deadline` is when an unconfirmed login stops being deniable
385
+ (Telegram confirms it for you), and `sensitive_actions_eligible_at` is
386
+ what `SESSION_TOO_FRESH_X` counts down to.
387
+ """
388
+ created = getattr(auth, "date_created", None)
389
+ period = autoconfirm_period or DEFAULT_AUTOCONFIRM_PERIOD
390
+ deny = created + timedelta(seconds=period) if isinstance(created, datetime) else None
391
+ eligible = created + FRESHNESS_WINDOW if isinstance(created, datetime) else None
392
+ return Session(
393
+ hash=str(getattr(auth, "hash", 0)),
394
+ current=bool(getattr(auth, "current", False)),
395
+ official_app=bool(getattr(auth, "official_app", False)),
396
+ unconfirmed=bool(getattr(auth, "unconfirmed", False)),
397
+ password_pending=bool(getattr(auth, "password_pending", False)),
398
+ app_name=getattr(auth, "app_name", "") or "",
399
+ app_version=getattr(auth, "app_version", "") or "",
400
+ api_id=getattr(auth, "api_id", None),
401
+ device_model=getattr(auth, "device_model", "") or "",
402
+ platform=getattr(auth, "platform", "") or "",
403
+ system_version=getattr(auth, "system_version", "") or "",
404
+ ip=getattr(auth, "ip", "") or "",
405
+ country=getattr(auth, "country", "") or "",
406
+ region=getattr(auth, "region", "") or "",
407
+ date_created=iso(created),
408
+ date_active=iso(getattr(auth, "date_active", None)),
409
+ call_requests_disabled=bool(getattr(auth, "call_requests_disabled", False)),
410
+ encrypted_requests_disabled=bool(getattr(auth, "encrypted_requests_disabled", False)),
411
+ deny_deadline=iso(deny) if getattr(auth, "unconfirmed", False) else None,
412
+ sensitive_actions_eligible_at=iso(eligible),
413
+ ttl_days=ttl_days,
414
+ )
415
+
416
+
417
+ def web_session_model(auth: Any, users: dict[int, Any] | None = None) -> WebSession:
418
+ bot_id = getattr(auth, "bot_id", None)
419
+ bot = (users or {}).get(int(bot_id)) if bot_id else None
420
+ return WebSession(
421
+ hash=str(getattr(auth, "hash", 0)),
422
+ bot=int(bot_id) if bot_id else None,
423
+ bot_username=getattr(bot, "username", None),
424
+ domain=getattr(auth, "domain", "") or "",
425
+ browser=getattr(auth, "browser", "") or "",
426
+ platform=getattr(auth, "platform", "") or "",
427
+ ip=getattr(auth, "ip", "") or "",
428
+ region=getattr(auth, "region", "") or "",
429
+ date_created=iso(getattr(auth, "date_created", None)),
430
+ date_active=iso(getattr(auth, "date_active", None)),
431
+ )
432
+
433
+
434
+ def passkey_model(raw: Any) -> Passkey:
435
+ created = getattr(raw, "date", None)
436
+ return Passkey(
437
+ id=str(getattr(raw, "id", "")),
438
+ name=getattr(raw, "name", "") or "",
439
+ date=iso(created),
440
+ date_unix=to_unix(created) if isinstance(created, datetime) else None,
441
+ last_usage_date=iso(getattr(raw, "last_usage_date", None)),
442
+ software_emoji_id=getattr(raw, "software_emoji_id", None),
443
+ )
444
+
445
+
446
+ def parse_hash(value: str, *, field: str = "hash") -> int:
447
+ """A session hash is a signed 64-bit integer, however it was typed."""
448
+ try:
449
+ return int(str(value).strip())
450
+ except (TypeError, ValueError) as exc:
451
+ raise UsageError(f"{value!r} is not a session hash", field=field) from exc
452
+
453
+
454
+ def now() -> datetime:
455
+ return datetime.now(timezone.utc)
456
+
457
+
458
+ # ---------------------------------------------------------------------------
459
+ # Passport
460
+ # ---------------------------------------------------------------------------
461
+
462
+ #: The thirteen `secureValueType*` constructors, by the name a user types.
463
+ SECURE_VALUE_TYPES: dict[str, str] = {
464
+ "personal_details": "SecureValueTypePersonalDetails",
465
+ "passport": "SecureValueTypePassport",
466
+ "driver_license": "SecureValueTypeDriverLicense",
467
+ "identity_card": "SecureValueTypeIdentityCard",
468
+ "internal_passport": "SecureValueTypeInternalPassport",
469
+ "address": "SecureValueTypeAddress",
470
+ "utility_bill": "SecureValueTypeUtilityBill",
471
+ "bank_statement": "SecureValueTypeBankStatement",
472
+ "rental_agreement": "SecureValueTypeRentalAgreement",
473
+ "passport_registration": "SecureValueTypePassportRegistration",
474
+ "temporary_registration": "SecureValueTypeTemporaryRegistration",
475
+ "phone": "SecureValueTypePhone",
476
+ "email": "SecureValueTypeEmail",
477
+ }
478
+
479
+
480
+ def secure_value_name(value: Any) -> str:
481
+ """`SecureValueTypeDriverLicense` → `driver_license`."""
482
+ raw = type(value).__name__.removeprefix("SecureValueType")
483
+ out = []
484
+ for index, character in enumerate(raw):
485
+ if character.isupper() and index:
486
+ out.append("_")
487
+ out.append(character.lower())
488
+ return "".join(out)
489
+
490
+
491
+ def secure_value_type(name: str) -> Any:
492
+ """The constructor for a document type a user named on the command line."""
493
+ from telethon.tl import types
494
+
495
+ key = name.strip().lower().replace("-", "_")
496
+ class_name = SECURE_VALUE_TYPES.get(key)
497
+ if class_name is None:
498
+ raise UsageError(
499
+ f"unknown Passport document type {name!r}; "
500
+ f"one of: {', '.join(sorted(SECURE_VALUE_TYPES))}",
501
+ field="type",
502
+ )
503
+ return getattr(types, class_name)()
504
+
505
+
506
+ def b64(data: bytes | None) -> str:
507
+ """URL-safe base64 without padding — how Telegram spells a login token."""
508
+ if not data:
509
+ return ""
510
+ return base64.urlsafe_b64encode(data).decode("ascii").rstrip("=")
511
+
512
+
513
+ def unb64(text: str) -> bytes:
514
+ """The inverse, tolerant of the padding a copy-paste may have dropped."""
515
+ cleaned = text.strip()
516
+ if "token=" in cleaned:
517
+ cleaned = cleaned.split("token=", 1)[1].split("&", 1)[0]
518
+ # `urlsafe_b64decode` has no `validate=`, and the padded decoder silently
519
+ # drops anything outside the alphabet — so `token=!!!!` would decode to
520
+ # empty bytes and be sent to Telegram as a login token. Check first.
521
+ if not cleaned or not _B64_RE.fullmatch(cleaned):
522
+ raise UsageError(f"{text!r} is not a tg://login token", field="link")
523
+ padding = "=" * (-len(cleaned) % 4)
524
+ try:
525
+ return base64.urlsafe_b64decode(cleaned + padding)
526
+ except Exception as exc:
527
+ raise UsageError(f"{text!r} is not a tg://login token", field="link") from exc
528
+
529
+
530
+ # ---------------------------------------------------------------------------
531
+ # Future auth tokens
532
+ # ---------------------------------------------------------------------------
533
+ #
534
+ # `auth.loggedOut.future_auth_token` lets the same device log back in without
535
+ # a code. It is a bearer credential: 0600 next to the session, capped at 20
536
+ # (Telegram's own limit), and dropped when the account is removed.
537
+
538
+ TOKEN_FILE = "future-auth-tokens"
539
+ MAX_TOKENS = 20
540
+
541
+
542
+ def token_path(manager: Any, alias: str) -> Any:
543
+ return manager.paths.account_dir(alias) / TOKEN_FILE
544
+
545
+
546
+ def future_tokens(manager: Any, alias: str) -> list[bytes]:
547
+ """The stored tokens, newest last. Unreadable or absent means none."""
548
+ path = token_path(manager, alias)
549
+ if not path.exists():
550
+ return []
551
+ try:
552
+ lines = path.read_text(encoding="utf-8").split()
553
+ except OSError:
554
+ return []
555
+ out: list[bytes] = []
556
+ for line in lines[-MAX_TOKENS:]:
557
+ try:
558
+ out.append(unb64(line))
559
+ except Exception:
560
+ continue
561
+ return out
562
+
563
+
564
+ def store_future_token(manager: Any, alias: str, token: bytes | None) -> bool:
565
+ """Append a token at 0600, evicting past the cap. Returns whether it stored."""
566
+ if not token:
567
+ return False
568
+ from tlgr.core.paths import write_private
569
+
570
+ path = token_path(manager, alias)
571
+ existing = path.read_text(encoding="utf-8").split() if path.exists() else []
572
+ write_private(path, "\n".join([*existing, b64(token)][-MAX_TOKENS:]))
573
+ return True
574
+
575
+
576
+ def drop_future_tokens(manager: Any, alias: str) -> None:
577
+ import contextlib
578
+
579
+ with contextlib.suppress(OSError):
580
+ token_path(manager, alias).unlink(missing_ok=True)
581
+
582
+
583
+ def already(ctx: Any) -> None:
584
+ """Flag `meta.already`: the world already looked the way the caller asked."""
585
+ mark = getattr(ctx, "mark_already", None)
586
+ if callable(mark):
587
+ mark()
588
+
589
+
590
+ def emit(ctx: Any, event_type: str, payload: dict[str, Any]) -> None:
591
+ """Echo an action onto the event bus, unless this op runs account-less.
592
+
593
+ The login and account-management operations take the alias in the
594
+ request rather than in `-a`, so `ctx.account` is empty for them — and the
595
+ bus keys every event by a *valid* alias. Emitting anyway turns a
596
+ successful logout into a USAGE error about an empty alias.
597
+ """
598
+ if (getattr(ctx, "account", "") or "").strip():
599
+ ctx.emit(event_type, payload)