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/daemon/preauth.py ADDED
@@ -0,0 +1,366 @@
1
+ """Login flows, owned by the daemon (§6.8).
2
+
3
+ Login moved into the daemon for one reason: **the daemon owns every session
4
+ file**. `tlgr account add` in v1 opened the session file from the CLI process
5
+ while the daemon might already hold it, which is precisely the two-writers
6
+ situation that earns `AUTH_KEY_DUPLICATED` and gets the session revoked.
7
+
8
+ There is a second reason that only shows up in a two-step login. Telethon
9
+ keeps `_phone_code_hash` in memory on the client object. A CLI that sends the
10
+ code in one process and signs in from another has lost the hash, so v1's
11
+ `account add` had to hold one process open across a human typing a code from
12
+ their phone. Here the pending login lives in the daemon, keyed by alias, with
13
+ a ten-minute bound, and `auth send-code` / `auth verify-code` are two ordinary
14
+ requests.
15
+
16
+ `auth.signUp` is never called *from a login*: a number with no account stops
17
+ with `signup_required` rather than quietly registering one. Registering is a
18
+ separate, explicitly consented command (`tlgr auth sign-up --accept-tos`).
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import asyncio
24
+ import contextlib
25
+ import json
26
+ import logging
27
+ import time
28
+ from collections.abc import AsyncIterator
29
+ from dataclasses import dataclass, field
30
+ from pathlib import Path
31
+ from typing import Any
32
+
33
+ from tlgr.core.errors import (
34
+ AuthenticationError,
35
+ AuthPasswordRequiredError,
36
+ UsageError,
37
+ )
38
+ from tlgr.core.paths import secure_session_files
39
+
40
+ log = logging.getLogger("tlgr.daemon.preauth")
41
+
42
+ __all__ = ["PendingLogin", "PreAuthService"]
43
+
44
+ #: A login a human abandoned must not pin a session file forever.
45
+ PENDING_TTL = 600.0
46
+
47
+
48
+ @dataclass
49
+ class PendingLogin:
50
+ alias: str
51
+ phone: str = ""
52
+ phone_code_hash: str = ""
53
+ code_type: str = ""
54
+ next_type: str = ""
55
+ timeout: int = 0
56
+ created: float = field(default_factory=time.monotonic)
57
+ client: Any = None
58
+ #: The `AccountSession` whose session-file lock this login holds.
59
+ session: Any = None
60
+
61
+ @property
62
+ def expired(self) -> bool:
63
+ return time.monotonic() - self.created > PENDING_TTL
64
+
65
+
66
+ class PreAuthService:
67
+ """The server side of `auth send-code|verify-code|password|qr`.
68
+
69
+ Holds a *pending* client per alias — connected, not yet authorised — and
70
+ the `phone_code_hash` that goes with it.
71
+ """
72
+
73
+ def __init__(self, sessions: Any) -> None:
74
+ self.sessions = sessions
75
+ self._pending: dict[str, PendingLogin] = {}
76
+ self._lock = asyncio.Lock()
77
+
78
+ # -- lifecycle ---------------------------------------------------------
79
+
80
+ def _sweep(self) -> None:
81
+ for alias, pending in list(self._pending.items()):
82
+ if pending.expired:
83
+ log.info("abandoning the expired login for %s", alias, extra={"account": alias})
84
+ self._pending.pop(alias, None)
85
+ _close(pending.client)
86
+
87
+ @property
88
+ def pending_count(self) -> int:
89
+ self._sweep()
90
+ return len(self._pending)
91
+
92
+ async def _client_for(self, alias: str) -> Any:
93
+ """A connected, unauthorised client for *alias*, created once."""
94
+ pending = self._pending.get(alias)
95
+ if pending is not None and not pending.expired:
96
+ return pending.client
97
+ session = self.sessions._make(alias)
98
+ session.lock.acquire()
99
+ client = session._factory(session.session_path, session.options)
100
+ await client.connect()
101
+ self._pending[alias] = PendingLogin(alias=alias, client=client, session=session)
102
+ return client
103
+
104
+ # -- the primitives the `auth` operations drive -------------------------
105
+ #
106
+ # PR-2 moved the *protocol* into `ops/auth.py`, where the TL requests and
107
+ # their errors belong, and left the daemon with what only the daemon can
108
+ # do: hand out a pre-auth client on a session file it already locks, and
109
+ # promote that client into a supervised session once the login lands.
110
+
111
+ async def client_for(
112
+ self, alias: str, *, api_id: int | None = None, api_hash: str | None = None
113
+ ) -> Any:
114
+ """A connected, unauthorised client for *alias*, created once.
115
+
116
+ Credentials are written first when they are supplied, because the
117
+ client cannot be built without them and an `auth send-code` that
118
+ carried an `--api-id` should not fail on a missing one.
119
+ """
120
+ if api_id and api_hash:
121
+ self.sessions.accounts.save_credentials(int(api_id), str(api_hash), alias)
122
+ async with self._lock:
123
+ return await self._client_for(alias)
124
+
125
+ def pending(self, alias: str) -> PendingLogin | None:
126
+ """The in-progress login for *alias*, or None. Never creates one."""
127
+ self._sweep()
128
+ return self._pending.get(alias)
129
+
130
+ def require(self, alias: str) -> PendingLogin:
131
+ """The in-progress login for *alias*, or a USAGE error naming the fix."""
132
+ return self._require(alias)
133
+
134
+ def remember(self, alias: str, **fields: Any) -> None:
135
+ """Update the pending login and mirror it into `login-state.json`.
136
+
137
+ The file is what makes an *unattended* login work across a daemon
138
+ restart: `auth send-code` in one process and `auth verify-code` in
139
+ another, half an hour later, is the shape an agent actually runs.
140
+ """
141
+ pending = self._pending.get(alias)
142
+ if pending is not None:
143
+ for name, value in fields.items():
144
+ if hasattr(pending, name):
145
+ setattr(pending, name, value)
146
+ state = self.read_state(alias)
147
+ state.update({k: v for k, v in fields.items() if isinstance(v, (str, int, float, bool))})
148
+ state["updated"] = time.time()
149
+ self.write_state(alias, state)
150
+
151
+ def state_path(self, alias: str) -> Path:
152
+ from tlgr.core.paths import validate_alias
153
+
154
+ directory: Path = self.sessions.paths.account_dir(validate_alias(alias))
155
+ return directory / "login-state.json"
156
+
157
+ def read_state(self, alias: str) -> dict[str, Any]:
158
+ path = self.state_path(alias)
159
+ if not path.exists():
160
+ return {}
161
+ try:
162
+ loaded = json.loads(path.read_text(encoding="utf-8"))
163
+ except (OSError, ValueError):
164
+ return {}
165
+ return loaded if isinstance(loaded, dict) else {}
166
+
167
+ def write_state(self, alias: str, data: dict[str, Any]) -> None:
168
+ """0600, always: a `phone_code_hash` is a login credential in flight."""
169
+ from tlgr.core.paths import write_private
170
+
171
+ self.sessions.paths.ensure_account_dir(alias)
172
+ write_private(self.state_path(alias), json.dumps(data, indent=2, sort_keys=True))
173
+
174
+ def clear_state(self, alias: str) -> None:
175
+ with contextlib.suppress(OSError):
176
+ self.state_path(alias).unlink(missing_ok=True)
177
+
178
+ async def finish(self, alias: str) -> dict[str, Any]:
179
+ """Record who we are and hand the session to the supervisor."""
180
+ self.clear_state(alias)
181
+ return await self._finish(alias)
182
+
183
+ def except_ids(self) -> list[int]:
184
+ """User ids already logged in here, so a QR login cannot re-add one."""
185
+ found: list[int] = []
186
+ for info in self.sessions.accounts.list_accounts():
187
+ if info.user_id:
188
+ found.append(int(info.user_id))
189
+ return found
190
+
191
+ def _require(self, alias: str) -> PendingLogin:
192
+ self._sweep()
193
+ pending = self._pending.get(alias)
194
+ if pending is None:
195
+ raise UsageError(
196
+ f"no login is in progress for {alias!r}; run: tlgr auth send-code first",
197
+ field="account",
198
+ )
199
+ return pending
200
+
201
+ # -- flows -------------------------------------------------------------
202
+
203
+ async def send_code(self, alias: str, phone: str) -> dict[str, Any]:
204
+ async with self._lock:
205
+ client = await self._client_for(alias)
206
+ try:
207
+ sent = await client.send_code_request(phone)
208
+ except Exception as exc:
209
+ raise _auth_error(exc) from exc
210
+ pending = self._pending[alias]
211
+ pending.phone = phone
212
+ pending.phone_code_hash = getattr(sent, "phone_code_hash", "") or ""
213
+ pending.code_type = type(getattr(sent, "type", None)).__name__
214
+ pending.next_type = type(getattr(sent, "next_type", None)).__name__
215
+ pending.timeout = int(getattr(sent, "timeout", 0) or 0)
216
+ return {
217
+ "account": alias,
218
+ "phone": _mask(phone),
219
+ "type": pending.code_type,
220
+ "next_type": pending.next_type or None,
221
+ "timeout": pending.timeout,
222
+ "expires_in": int(PENDING_TTL),
223
+ }
224
+
225
+ async def resend_code(self, alias: str) -> dict[str, Any]:
226
+ pending = self._require(alias)
227
+ try:
228
+ sent = await pending.client.send_code_request(pending.phone, force_sms=True)
229
+ except Exception as exc:
230
+ raise _auth_error(exc) from exc
231
+ pending.phone_code_hash = getattr(sent, "phone_code_hash", "") or ""
232
+ return {
233
+ "account": alias,
234
+ "resent": True,
235
+ "type": type(getattr(sent, "type", None)).__name__,
236
+ }
237
+
238
+ async def verify_code(self, alias: str, code: str) -> dict[str, Any]:
239
+ pending = self._require(alias)
240
+ try:
241
+ await pending.client.sign_in(
242
+ phone=pending.phone, code=code, phone_code_hash=pending.phone_code_hash
243
+ )
244
+ except Exception as exc:
245
+ if type(exc).__name__ == "SessionPasswordNeededError":
246
+ raise AuthPasswordRequiredError(
247
+ "this account has a two-factor password; "
248
+ "supply it with tlgr auth password --password-env TLGR_2FA_PASSWORD"
249
+ ) from exc
250
+ if type(exc).__name__ == "PhoneNumberUnoccupiedError":
251
+ raise UsageError(
252
+ "that number has no Telegram account. Registering one is a separate, "
253
+ "explicit step: tlgr auth sign-up --first-name … --accept-tos"
254
+ ) from exc
255
+ raise _auth_error(exc) from exc
256
+ return await self._finish(alias)
257
+
258
+ async def password(self, alias: str, password: str) -> dict[str, Any]:
259
+ pending = self._require(alias)
260
+ try:
261
+ await pending.client.sign_in(password=password)
262
+ except Exception as exc:
263
+ raise _auth_error(exc) from exc
264
+ return await self._finish(alias)
265
+
266
+ async def qr(self, alias: str) -> AsyncIterator[dict[str, Any]]:
267
+ """Stream QR login tokens until one is accepted (§6.8).
268
+
269
+ QR is the login method that always works for a third-party `api_id`:
270
+ the code path can answer `UPDATE_APP_TO_LOGIN`, which no amount of
271
+ retrying fixes.
272
+ """
273
+ async with self._lock:
274
+ client = await self._client_for(alias)
275
+ deadline = time.monotonic() + PENDING_TTL
276
+ while time.monotonic() < deadline:
277
+ login = await client.qr_login()
278
+ yield {
279
+ "type": "qr",
280
+ "url": login.url,
281
+ "expires": getattr(login, "expires", None),
282
+ }
283
+ try:
284
+ await login.wait(timeout=min(60, deadline - time.monotonic()))
285
+ except asyncio.TimeoutError:
286
+ continue
287
+ except Exception as exc:
288
+ if type(exc).__name__ == "SessionPasswordNeededError":
289
+ yield {"type": "password_required"}
290
+ return
291
+ raise _auth_error(exc) from exc
292
+ yield {"type": "done", **await self._finish(alias)}
293
+ return
294
+ yield {"type": "end", "ok": False, "reason": "the QR login timed out"}
295
+
296
+ async def _finish(self, alias: str) -> dict[str, Any]:
297
+ """Record who we are, release the pending client, hand over to the manager."""
298
+ pending = self._pending.pop(alias)
299
+ me = await pending.client.get_me()
300
+ with contextlib.suppress(Exception):
301
+ await pending.client.disconnect()
302
+ # The session file is a complete account credential and Telethon
303
+ # writes it with whatever the umask allowed (SEC-07).
304
+ with contextlib.suppress(Exception):
305
+ secure_session_files(self.sessions.paths.session(alias))
306
+ if pending.session is not None:
307
+ pending.session.lock.release()
308
+ self.sessions.accounts.update_account(
309
+ alias,
310
+ phone=getattr(me, "phone", None),
311
+ username=getattr(me, "username", None),
312
+ first_name=getattr(me, "first_name", None),
313
+ user_id=getattr(me, "id", None),
314
+ )
315
+ self.sessions.accounts.set_health(
316
+ alias, "stopped", reason="", user_id=getattr(me, "id", None)
317
+ )
318
+ # Reconnect through the normal path so the account is supervised from
319
+ # the first second rather than living on the pre-auth client.
320
+ await self.sessions.ensure(alias)
321
+ return {
322
+ "account": alias,
323
+ "user_id": getattr(me, "id", None),
324
+ "username": getattr(me, "username", None),
325
+ "authorized": True,
326
+ }
327
+
328
+ async def cancel(self, alias: str) -> dict[str, Any]:
329
+ pending = self._pending.pop(alias, None)
330
+ if pending is None:
331
+ return {"account": alias, "cancelled": False}
332
+ _close(pending.client)
333
+ if pending.session is not None:
334
+ pending.session.lock.release()
335
+ return {"account": alias, "cancelled": True}
336
+
337
+
338
+ #: Disconnects fired from a sweep have no caller to await them; the set keeps
339
+ #: a reference so the loop cannot collect the task mid-disconnect (COR-41).
340
+ _closing: set[asyncio.Task[Any]] = set()
341
+
342
+
343
+ def _close(client: Any) -> None:
344
+ if client is None:
345
+ return
346
+ with contextlib.suppress(Exception):
347
+ task = asyncio.get_event_loop().create_task(client.disconnect())
348
+ _closing.add(task)
349
+ task.add_done_callback(_closing.discard)
350
+
351
+
352
+ def _auth_error(exc: BaseException) -> BaseException:
353
+ from tlgr.core.errors import rule_for
354
+
355
+ rule = rule_for(exc)
356
+ if rule.code in ("USAGE", "RATE_LIMITED", "AUTH_PASSWORD_REQUIRED"):
357
+ return exc
358
+ return AuthenticationError(str(exc))
359
+
360
+
361
+ def _mask(phone: str) -> str:
362
+ """Never echo a full phone number back; it is in the log and the reply."""
363
+ digits = "".join(c for c in phone if c.isdigit())
364
+ if len(digits) < 6:
365
+ return "***"
366
+ return f"{digits[:3]}…{digits[-2:]}"