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/core/errors.py ADDED
@@ -0,0 +1,687 @@
1
+ """Error types, stable exit codes, and the one Telethon-exception table.
2
+
3
+ Everything that turns a raised exception into something a caller can act on
4
+ happens here: the machine name (`code`), the process exit status, the HTTP
5
+ status the daemon answers with, whether a retry is worth attempting, and the
6
+ hint a human reads. v1 spread that decision across four modules and collapsed
7
+ most of it to IPC_ERROR/exit 12 on the way out (COR-06); one table cannot
8
+ disagree with itself.
9
+
10
+ **Why the table is keyed by class *name*.** ARCHITECTURE §2.2 requires that
11
+ `cli/` never import Telethon — `tlgr --help` has to stay fast and work on a
12
+ machine with no Telethon installed — while §7.1 requires that this module be
13
+ the only place Telethon exception classes are named. Both hold only if the
14
+ names are strings: `classify()` walks `type(exc).__mro__` and looks each class
15
+ name up, so nothing is imported to classify anything.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import json
21
+ import re
22
+ import sys
23
+ from dataclasses import dataclass
24
+ from typing import Any
25
+
26
+ import msgspec
27
+
28
+ from tlgr.models.error import ErrorBody
29
+
30
+ # Stable exit codes for automation/agent consumption.
31
+ EXIT_SUCCESS = 0
32
+ EXIT_GENERIC = 1
33
+ EXIT_USAGE = 2
34
+ EXIT_EMPTY = 3
35
+ EXIT_AUTH = 4
36
+ EXIT_NOT_FOUND = 5
37
+ EXIT_PERMISSION = 6
38
+ EXIT_RATE_LIMITED = 7
39
+ EXIT_RETRYABLE = 8
40
+ EXIT_SPAM_FLAGGED = 9
41
+ EXIT_CONFIG = 10
42
+ EXIT_DAEMON = 11
43
+ EXIT_IPC = 12
44
+ EXIT_INDETERMINATE = 13
45
+ EXIT_CANCELLED = 130
46
+
47
+ #: `MessageNotModifiedError` is not a failure: the world already looks the way
48
+ #: the caller asked for. `classify()` returns this code with `exit_code == 0`,
49
+ #: and the dispatcher turns it into `ok: true` with `meta.already = true`.
50
+ NOT_MODIFIED = "NOT_MODIFIED"
51
+
52
+ EXIT_CODE_MAP: dict[str, dict[str, Any]] = {
53
+ "SUCCESS": {"code": EXIT_SUCCESS, "description": "Success"},
54
+ "GENERIC": {"code": EXIT_GENERIC, "description": "Generic failure"},
55
+ "USAGE": {"code": EXIT_USAGE, "description": "Usage or parse error"},
56
+ "EMPTY": {"code": EXIT_EMPTY, "description": "Empty results"},
57
+ "AUTH_ERROR": {"code": EXIT_AUTH, "description": "Authentication required"},
58
+ "AUTH_PASSWORD_REQUIRED": {
59
+ "code": EXIT_AUTH,
60
+ "description": "Two-factor password required to complete sign-in",
61
+ },
62
+ "SESSION_ERROR": {"code": EXIT_AUTH, "description": "Session error (re-auth needed)"},
63
+ "CHAT_NOT_FOUND": {"code": EXIT_NOT_FOUND, "description": "Chat or entity not found"},
64
+ "NOT_FOUND": {
65
+ "code": EXIT_NOT_FOUND,
66
+ "description": "Chat, user, message or account not found",
67
+ },
68
+ "ACCOUNT_NOT_FOUND": {"code": EXIT_NOT_FOUND, "description": "Account alias is not registered"},
69
+ "ACCOUNT_REQUIRED": {"code": EXIT_USAGE, "description": "No account given and none inferable"},
70
+ "PERMISSION_DENIED": {"code": EXIT_PERMISSION, "description": "Permission denied"},
71
+ "RATE_LIMITED": {"code": EXIT_RATE_LIMITED, "description": "Rate limited (retry later)"},
72
+ "RETRYABLE": {"code": EXIT_RETRYABLE, "description": "Transient/retryable error"},
73
+ "PEER_FLOOD": {
74
+ "code": EXIT_SPAM_FLAGGED,
75
+ "description": "Account spam-flagged for messaging strangers (PeerFlood) — stop sending",
76
+ },
77
+ "ACCOUNT_FROZEN": {
78
+ "code": EXIT_SPAM_FLAGGED,
79
+ "description": "Account frozen/restricted by Telegram — stop sending",
80
+ },
81
+ "CONFIG_ERROR": {"code": EXIT_CONFIG, "description": "Configuration error"},
82
+ "DAEMON_ERROR": {"code": EXIT_DAEMON, "description": "Daemon error"},
83
+ "DAEMON_NOT_RUNNING": {"code": EXIT_DAEMON, "description": "Daemon is not running"},
84
+ "DAEMON_VERSION_MISMATCH": {
85
+ "code": EXIT_DAEMON,
86
+ "description": "Daemon speaks a different protocol version than this CLI",
87
+ },
88
+ "IPC_ERROR": {"code": EXIT_IPC, "description": "IPC communication error"},
89
+ "NOT_SUPPORTED": {
90
+ "code": EXIT_INDETERMINATE,
91
+ "description": (
92
+ "tlgr cannot perform this — the API layer or Telethon build lacks it, "
93
+ "not a failure of the request"
94
+ ),
95
+ },
96
+ "INDETERMINATE": {
97
+ "code": EXIT_INDETERMINATE,
98
+ "description": (
99
+ "Question could not be answered authoritatively — treat as unknown, never as a negative"
100
+ ),
101
+ },
102
+ "CANCELLED": {"code": EXIT_CANCELLED, "description": "Interrupted (SIGINT)"},
103
+ }
104
+
105
+
106
+ # ---------------------------------------------------------------------------
107
+ # The exception tree
108
+ # ---------------------------------------------------------------------------
109
+
110
+
111
+ class TlgrError(Exception):
112
+ """Base error for all tlgr errors."""
113
+
114
+ code: str = "TLGR_ERROR"
115
+ exit_code: int = EXIT_GENERIC
116
+ hint: str = ""
117
+ http: int = 500
118
+ retryable: bool = False
119
+
120
+ def __init__(self, message: str, code: str | None = None):
121
+ super().__init__(message)
122
+ if code:
123
+ self.code = code
124
+
125
+
126
+ class UsageError(TlgrError):
127
+ code = "USAGE"
128
+ exit_code = EXIT_USAGE
129
+ http = 400
130
+
131
+ def __init__(self, message: str, code: str | None = None, field: str | None = None):
132
+ super().__init__(message, code)
133
+ self.field = field
134
+
135
+
136
+ class AuthenticationError(TlgrError):
137
+ code = "AUTH_ERROR"
138
+ exit_code = EXIT_AUTH
139
+ http = 401
140
+ hint = "Run: tlgr account add <phone>"
141
+
142
+
143
+ class AuthPasswordRequiredError(AuthenticationError):
144
+ code = "AUTH_PASSWORD_REQUIRED"
145
+ hint = "Supply the 2FA password with --password-env TLGR_2FA_PASSWORD"
146
+
147
+
148
+ class SessionError(TlgrError):
149
+ code = "SESSION_ERROR"
150
+ exit_code = EXIT_AUTH
151
+ http = 401
152
+ hint = "Session expired. Run: tlgr account add <phone>"
153
+
154
+
155
+ class ConfigurationError(TlgrError):
156
+ code = "CONFIG_ERROR"
157
+ exit_code = EXIT_CONFIG
158
+ http = 400
159
+ hint = "Run: tlgr config init"
160
+
161
+
162
+ class NotFoundError(TlgrError):
163
+ code = "NOT_FOUND"
164
+ exit_code = EXIT_NOT_FOUND
165
+ http = 404
166
+
167
+
168
+ class ChatNotFoundError(NotFoundError):
169
+ code = "CHAT_NOT_FOUND"
170
+ hint = "Run: tlgr chat list to find available chats"
171
+
172
+
173
+ class AccountNotFoundError(NotFoundError):
174
+ code = "ACCOUNT_NOT_FOUND"
175
+ hint = "Run: tlgr account list to see registered aliases"
176
+
177
+
178
+ class AccountRequiredError(TlgrError):
179
+ """No account was given and the daemon refuses to pick one for you.
180
+
181
+ v1 used "whichever alias came first out of a set" (COR-02), which meant a
182
+ two-account user could send from the wrong identity without any signal.
183
+ """
184
+
185
+ code = "ACCOUNT_REQUIRED"
186
+ exit_code = EXIT_USAGE
187
+ http = 400
188
+ hint = "Pass -a <alias>, set TLGR_ACCOUNT, or set [accounts] default in config.toml"
189
+
190
+
191
+ class PermissionError_(TlgrError):
192
+ code = "PERMISSION_DENIED"
193
+ exit_code = EXIT_PERMISSION
194
+ http = 403
195
+
196
+
197
+ class RateLimitError(TlgrError):
198
+ code = "RATE_LIMITED"
199
+ exit_code = EXIT_RATE_LIMITED
200
+ http = 429
201
+ retryable = True
202
+
203
+ def __init__(self, message: str, wait_seconds: int = 0):
204
+ super().__init__(message, code="RATE_LIMITED")
205
+ self.wait_seconds = wait_seconds
206
+ if wait_seconds:
207
+ self.hint = f"Rate limited. Retry after {wait_seconds}s"
208
+
209
+
210
+ class SpamFlagError(TlgrError):
211
+ """Telegram has restricted this account from messaging strangers.
212
+
213
+ Distinct from RateLimitError: a FloodWait clears after `wait_seconds`,
214
+ whereas PeerFlood/FROZEN is an account-level spam flag with no advertised
215
+ expiry. Callers running outreach must stop ALL outgoing traffic for the
216
+ account rather than back off and retry.
217
+ """
218
+
219
+ code = "PEER_FLOOD"
220
+ exit_code = EXIT_SPAM_FLAGGED
221
+ http = 403
222
+ hint = "Account is spam-flagged. Stop sending from it and let it rest."
223
+
224
+
225
+ class AccountFrozenError(SpamFlagError):
226
+ code = "ACCOUNT_FROZEN"
227
+ hint = "Account is frozen by Telegram. Appeal before sending anything else."
228
+
229
+
230
+ class RetryableError(TlgrError):
231
+ code = "RETRYABLE"
232
+ exit_code = EXIT_RETRYABLE
233
+ http = 503
234
+ retryable = True
235
+
236
+
237
+ class IndeterminateError(TlgrError):
238
+ """The answer could not be established — and must never be reported as "no".
239
+
240
+ Exit 13 exists because a truncated scan, a flood mid-harvest or an RPC
241
+ failure during a *negative* proof are all "we do not know", and a caller
242
+ that reads them as "no" acts on a fact nobody established.
243
+ """
244
+
245
+ code = "INDETERMINATE"
246
+ exit_code = EXIT_INDETERMINATE
247
+ http = 200
248
+
249
+
250
+ class NotSupportedError(TlgrError):
251
+ """tlgr cannot do this, and no retry will change that.
252
+
253
+ Shares exit 13 with INDETERMINATE because both mean "do not read this as
254
+ a no about the world": a feature Telethon's layer does not carry has not
255
+ been refused by Telegram, it was never asked. The code is distinct so an
256
+ agent can tell "unavailable in this build" from "could not establish".
257
+ """
258
+
259
+ code = "NOT_SUPPORTED"
260
+ exit_code = EXIT_INDETERMINATE
261
+ http = 501
262
+
263
+
264
+ class DaemonError(TlgrError):
265
+ code = "DAEMON_ERROR"
266
+ exit_code = EXIT_DAEMON
267
+ http = 500
268
+ hint = "Run: tlgr daemon start"
269
+
270
+
271
+ class DaemonNotRunningError(DaemonError):
272
+ code = "DAEMON_NOT_RUNNING"
273
+ exit_code = EXIT_DAEMON
274
+ hint = "Daemon is not running. Start it with: tlgr daemon start"
275
+
276
+
277
+ class DaemonVersionMismatchError(DaemonError):
278
+ code = "DAEMON_VERSION_MISMATCH"
279
+ http = 409
280
+ hint = "Run: tlgr daemon restart to pick up the new protocol"
281
+
282
+
283
+ class IPCError(TlgrError):
284
+ code = "IPC_ERROR"
285
+ exit_code = EXIT_IPC
286
+ http = 500
287
+ retryable = True
288
+ hint = "Check daemon status with: tlgr daemon status"
289
+
290
+
291
+ class CancelledError(TlgrError):
292
+ code = "CANCELLED"
293
+ exit_code = EXIT_CANCELLED
294
+
295
+
296
+ # ---------------------------------------------------------------------------
297
+ # The mapping table (ARCHITECTURE §7.2)
298
+ # ---------------------------------------------------------------------------
299
+
300
+
301
+ @dataclass(frozen=True, slots=True)
302
+ class ErrorRule:
303
+ """One row of the §7.2 table."""
304
+
305
+ code: str
306
+ exit_code: int
307
+ http: int
308
+ retryable: bool = False
309
+ hint: str = ""
310
+
311
+
312
+ _RATE = ErrorRule("RATE_LIMITED", EXIT_RATE_LIMITED, 429, True)
313
+ _SESSION = ErrorRule(
314
+ "SESSION_ERROR", EXIT_AUTH, 401, False, "Re-authenticate: tlgr account add <phone>"
315
+ )
316
+ _AUTH = ErrorRule("AUTH_ERROR", EXIT_AUTH, 401)
317
+ _NOT_FOUND = ErrorRule("NOT_FOUND", EXIT_NOT_FOUND, 404)
318
+ _DENIED = ErrorRule("PERMISSION_DENIED", EXIT_PERMISSION, 403)
319
+ _PREMIUM = ErrorRule("PERMISSION_DENIED", EXIT_PERMISSION, 403, False, "Requires Telegram Premium")
320
+ _PAYMENT = ErrorRule(
321
+ "PERMISSION_DENIED", EXIT_PERMISSION, 403, False, "Not enough Stars/balance for this operation"
322
+ )
323
+ _USAGE = ErrorRule("USAGE", EXIT_USAGE, 400)
324
+ _RETRY = ErrorRule("RETRYABLE", EXIT_RETRYABLE, 503, True)
325
+
326
+ #: Telethon (and internal) exception class name -> rule. Keyed by name so that
327
+ #: importing this module never imports Telethon; see the module docstring.
328
+ ERROR_MAP: dict[str, ErrorRule] = {
329
+ # --- waits -------------------------------------------------------------
330
+ "FloodWaitError": _RATE,
331
+ "SlowModeWaitError": _RATE,
332
+ "FloodPremiumWaitError": _RATE,
333
+ "FloodTestPhoneWaitError": _RATE,
334
+ "TakeoutInitDelayError": _RATE,
335
+ "PreviousChatImportActiveWaitXminError": _RATE,
336
+ "TwoFaConfirmWaitError": _RATE,
337
+ "_2faConfirmWaitError": _RATE,
338
+ # --- account-level stop signs -----------------------------------------
339
+ "PeerFloodError": ErrorRule(
340
+ "PEER_FLOOD",
341
+ EXIT_SPAM_FLAGGED,
342
+ 403,
343
+ False,
344
+ "Account is spam-flagged. Stop sending from it and let it rest.",
345
+ ),
346
+ "FrozenMethodInvalidError": ErrorRule(
347
+ "ACCOUNT_FROZEN", EXIT_SPAM_FLAGGED, 403, False, "Account is frozen; appeal before retrying"
348
+ ),
349
+ # --- session / auth ----------------------------------------------------
350
+ "AuthKeyUnregisteredError": _SESSION,
351
+ "AuthKeyInvalidError": _SESSION,
352
+ "AuthKeyPermEmptyError": _SESSION,
353
+ "SessionRevokedError": _SESSION,
354
+ "SessionExpiredError": _SESSION,
355
+ "AuthKeyDuplicatedError": _SESSION,
356
+ "UserDeactivatedError": _SESSION,
357
+ "UserDeactivatedBanError": _SESSION,
358
+ "AuthKeyNotFound": _SESSION,
359
+ "SessionPasswordNeededError": ErrorRule(
360
+ "AUTH_PASSWORD_REQUIRED",
361
+ EXIT_AUTH,
362
+ 401,
363
+ False,
364
+ "Supply the 2FA password with --password-env TLGR_2FA_PASSWORD",
365
+ ),
366
+ "PhoneCodeInvalidError": _AUTH,
367
+ "PhoneCodeExpiredError": _AUTH,
368
+ "PhoneNumberInvalidError": _AUTH,
369
+ "PhoneNumberBannedError": _AUTH,
370
+ "PhoneNumberUnoccupiedError": _AUTH,
371
+ "PasswordHashInvalidError": _AUTH,
372
+ "UpdateAppToLoginError": ErrorRule(
373
+ "AUTH_ERROR", EXIT_AUTH, 401, False, "This login method is retired; use QR login instead"
374
+ ),
375
+ # --- not found ---------------------------------------------------------
376
+ "UsernameNotOccupiedError": _NOT_FOUND,
377
+ "UsernameInvalidError": _NOT_FOUND,
378
+ "PeerIdInvalidError": _NOT_FOUND,
379
+ "ChannelInvalidError": _NOT_FOUND,
380
+ "ChatIdInvalidError": _NOT_FOUND,
381
+ "UserIdInvalidError": _NOT_FOUND,
382
+ "MessageIdInvalidError": _NOT_FOUND,
383
+ "MsgIdInvalidError": _NOT_FOUND,
384
+ "InviteHashExpiredError": _NOT_FOUND,
385
+ "InviteHashInvalidError": _NOT_FOUND,
386
+ "StickersetInvalidError": _NOT_FOUND,
387
+ # --- permission --------------------------------------------------------
388
+ "ChatAdminRequiredError": _DENIED,
389
+ "ChatWriteForbiddenError": _DENIED,
390
+ "ChatSendMediaForbiddenError": _DENIED,
391
+ "ChatSendStickersForbiddenError": _DENIED,
392
+ "ChatSendGifsForbiddenError": _DENIED,
393
+ "ChatSendGameForbiddenError": _DENIED,
394
+ "ChatSendInlineForbiddenError": _DENIED,
395
+ "ChatSendPollForbiddenError": _DENIED,
396
+ "ChatSendPhotosForbiddenError": _DENIED,
397
+ "ChatSendVideosForbiddenError": _DENIED,
398
+ "ChatSendAudiosForbiddenError": _DENIED,
399
+ "ChatSendVoicesForbiddenError": _DENIED,
400
+ "ChatSendRoundvideosForbiddenError": _DENIED,
401
+ "ChatSendDocsForbiddenError": _DENIED,
402
+ "ChatSendPlainForbiddenError": _DENIED,
403
+ "ChannelPrivateError": _DENIED,
404
+ "UserPrivacyRestrictedError": _DENIED,
405
+ "UserIsBlockedError": _DENIED,
406
+ "UserBannedInChannelError": _DENIED,
407
+ "UserNotParticipantError": _DENIED,
408
+ "MessageDeleteForbiddenError": _DENIED,
409
+ "MessageAuthorRequiredError": _DENIED,
410
+ "MessageEditTimeExpiredError": _DENIED,
411
+ "RightForbiddenError": _DENIED,
412
+ "ChatForwardsRestrictedError": _DENIED,
413
+ "TopicClosedError": _DENIED,
414
+ "BroadcastForbiddenError": _DENIED,
415
+ "ForbiddenError": _DENIED,
416
+ "PremiumAccountRequiredError": _PREMIUM,
417
+ "PrivacyPremiumRequiredError": _PREMIUM,
418
+ "BoostsRequiredError": _PREMIUM,
419
+ "BalanceTooLowError": _PAYMENT,
420
+ "AllowPaymentRequiredError": _PAYMENT,
421
+ "StarsFormAmountMismatchError": _PAYMENT,
422
+ "FormExpiredError": _PAYMENT,
423
+ # --- not an error ------------------------------------------------------
424
+ "MessageNotModifiedError": ErrorRule(NOT_MODIFIED, EXIT_SUCCESS, 200),
425
+ # --- usage -------------------------------------------------------------
426
+ "MessageEmptyError": _USAGE,
427
+ "MessageTooLongError": _USAGE,
428
+ "MediaEmptyError": _USAGE,
429
+ "MediaInvalidError": _USAGE,
430
+ "PhotoInvalidDimensionsError": _USAGE,
431
+ "ContactIdInvalidError": _USAGE,
432
+ "UserAlreadyParticipantError": _USAGE,
433
+ "UsersTooMuchError": _USAGE,
434
+ "BotMethodInvalidError": _USAGE,
435
+ "BannedRightsInvalidError": _USAGE,
436
+ "ScheduleDateInvalidError": _USAGE,
437
+ "BadRequestError": _USAGE,
438
+ "ValidationError": _USAGE,
439
+ "UsageError": _USAGE,
440
+ # --- transient ---------------------------------------------------------
441
+ "FileReferenceExpiredError": _RETRY,
442
+ "FileReferenceInvalidError": _RETRY,
443
+ "FilerefUpgradeNeededError": _RETRY,
444
+ "ServerError": _RETRY,
445
+ "RpcCallFailError": _RETRY,
446
+ "RpcMcgetFailError": _RETRY,
447
+ "InterdcCallErrorError": _RETRY,
448
+ "TimedOutError": _RETRY,
449
+ "PersistentTimestampOutdatedError": _RETRY,
450
+ "TimeoutError": _RETRY,
451
+ "ConnectionError": _RETRY,
452
+ "OSError": _RETRY,
453
+ # --- everything else ---------------------------------------------------
454
+ "RPCError": ErrorRule("GENERIC", EXIT_GENERIC, 500),
455
+ "KeyboardInterrupt": ErrorRule("CANCELLED", EXIT_CANCELLED, 500),
456
+ }
457
+
458
+ #: RPC message patterns that outrank the class name. Telethon has no generated
459
+ #: class for every string in the 780-entry error DB, and `FROZEN_*` in
460
+ #: particular arrives as a bare RPCError.
461
+ _MESSAGE_RULES: tuple[tuple[re.Pattern[str], ErrorRule], ...] = (
462
+ (
463
+ # Anywhere in the message, not only at the start: Telethon renders an
464
+ # unknown RPC error as "RPCError None: FROZEN_METHOD_INVALID (caused
465
+ # by …)", so anchoring this pattern made it match only the bare code.
466
+ re.compile(r"\bFROZEN_[A-Z_]+"),
467
+ ErrorRule("ACCOUNT_FROZEN", EXIT_SPAM_FLAGGED, 403, False, "Account is frozen by Telegram"),
468
+ ),
469
+ (re.compile(r"^FLOOD_WAIT_\d+$"), _RATE),
470
+ (re.compile(r"^SLOWMODE_WAIT_\d+$"), _RATE),
471
+ (re.compile(r"Cannot send requests while disconnected"), _RETRY),
472
+ (re.compile(r"Could not find the input entity"), _NOT_FOUND),
473
+ (re.compile(r"^AUTH_KEY_(UNREGISTERED|INVALID|DUPLICATED|PERM_EMPTY)$"), _SESSION),
474
+ # PR-12. The settings surface names things by slug, id and code, and
475
+ # Telethon has no generated class for any of these — without a rule they
476
+ # all arrive as exit 1, which tells a caller nothing about whether to
477
+ # retry, to fix the name, or to give up.
478
+ (
479
+ re.compile(
480
+ r"\b(GIFT_SLUG_INVALID|SLUG_INVALID|THEME_INVALID|LANG_PACK_INVALID"
481
+ r"|COLLECTION_ID_INVALID|AUCTION_INVALID|BUSINESS_LINK_INVALID"
482
+ r"|STARGIFT_INVALID|SHORTCUT_INVALID|RINGTONE_INVALID)\b"
483
+ ),
484
+ _NOT_FOUND,
485
+ ),
486
+ # Limits the server enforces and names. They are usage errors, not
487
+ # failures: the fix is to pass fewer of something.
488
+ (
489
+ re.compile(
490
+ r"\b(CHATLINKS_TOO_MUCH|QUICK_REPLIES_TOO_MUCH|REPLY_MESSAGES_TOO_MUCH"
491
+ r"|USERNAMES_ACTIVE_TOO_MUCH|BIRTHDAY_INVALID|TTL_DAYS_INVALID)\b"
492
+ ),
493
+ _USAGE,
494
+ ),
495
+ # `USERNAME_PURCHASE_AVAILABLE` means the name is free *on Fragment*,
496
+ # which is neither "taken" nor an error tlgr can retry past.
497
+ (re.compile(r"\bUSERNAME_PURCHASE_AVAILABLE\b"), _USAGE),
498
+ )
499
+
500
+ _GENERIC = ErrorRule("GENERIC", EXIT_GENERIC, 500)
501
+
502
+ #: Trailing numbers are parameters, not part of the name: FLOOD_WAIT_42 and
503
+ #: FLOOD_WAIT_3 are the same error with a different wait.
504
+ _SUFFIX_RE = re.compile(r"_(\d+)$")
505
+
506
+ #: msgspec ends a validation message with " - at $.chat.kind"; that suffix is
507
+ #: what turns a USAGE error into an actionable one (`error.field`).
508
+ _FIELD_RE = re.compile(r"\s-\s+at\s+`?\$\.?([^`]+)`?\s*$")
509
+
510
+
511
+ def strip_numeric_suffix(rpc_message: str) -> tuple[str, int | None]:
512
+ """Split `FLOOD_WAIT_42` into `("FLOOD_WAIT_X", 42)`."""
513
+ m = _SUFFIX_RE.search(rpc_message)
514
+ if not m:
515
+ return rpc_message, None
516
+ return rpc_message[: m.start()] + "_X", int(m.group(1))
517
+
518
+
519
+ def rule_for(exc: BaseException) -> ErrorRule:
520
+ """Find the §7.2 row for *exc*, most specific class first.
521
+
522
+ Walks the MRO by name so that a Telethon subclass we have never heard of
523
+ still lands on its base's row (every `*ForbiddenError` under
524
+ `ForbiddenError` becomes PERMISSION_DENIED rather than GENERIC).
525
+ """
526
+ if isinstance(exc, TlgrError):
527
+ return ErrorRule(exc.code, exc.exit_code, exc.http, exc.retryable, exc.hint)
528
+
529
+ message = str(exc)
530
+ for pattern, message_rule in _MESSAGE_RULES:
531
+ if pattern.search(message):
532
+ return message_rule
533
+
534
+ for klass in type(exc).__mro__:
535
+ found = ERROR_MAP.get(klass.__name__)
536
+ if found is not None:
537
+ return found
538
+ return _GENERIC
539
+
540
+
541
+ def classify(exc: BaseException, *, account: str | None = None, request_id: str = "") -> ErrorBody:
542
+ """Turn any exception into the wire error shape.
543
+
544
+ This is the single funnel: the daemon calls it before answering, the
545
+ legacy IPC handler calls it during migration, and the CLI calls it for
546
+ anything raised locally, so an unmigrated command gets the right exit code
547
+ on day one (COR-06).
548
+ """
549
+ rule = rule_for(exc)
550
+ body = ErrorBody(
551
+ code=rule.code,
552
+ message=str(exc) or type(exc).__name__,
553
+ exit_code=rule.exit_code,
554
+ retryable=rule.retryable,
555
+ hint=rule.hint or getattr(exc, "hint", "") or None,
556
+ account=account,
557
+ request_id=request_id or None,
558
+ )
559
+
560
+ # FloodWaitError carries the wait as an attribute; a bare RPCError carries
561
+ # it in the message (FLOOD_WAIT_42). Both must reach the caller, because
562
+ # "retry later" without "how much later" is not actionable.
563
+ seconds = getattr(exc, "seconds", None)
564
+ if not isinstance(seconds, int):
565
+ seconds = getattr(exc, "wait_seconds", None) or None
566
+ if rule.code == "RATE_LIMITED":
567
+ if not isinstance(seconds, int):
568
+ _, parsed = strip_numeric_suffix(str(exc).strip())
569
+ seconds = parsed
570
+ if isinstance(seconds, int):
571
+ body.wait_seconds = seconds
572
+ if not body.hint:
573
+ body.hint = f"Retry after {seconds}s, or raise --flood-wait-max."
574
+
575
+ if rule.code == "USAGE":
576
+ field = getattr(exc, "field", None)
577
+ if not field:
578
+ m = _FIELD_RE.search(str(exc))
579
+ if m:
580
+ field = m.group(1).strip()
581
+ body.field = field or None
582
+
583
+ rpc_code = getattr(exc, "code", None)
584
+ rpc_message = getattr(exc, "message", None)
585
+ if isinstance(rpc_code, int) and isinstance(rpc_message, str):
586
+ rpc: dict[str, Any] = {"code": rpc_code, "message": rpc_message}
587
+ method = getattr(exc, "request", None)
588
+ if method is not None:
589
+ rpc["method"] = type(method).__name__
590
+ body.rpc = rpc
591
+
592
+ reason = getattr(exc, "reason", None)
593
+ if isinstance(reason, str) and reason:
594
+ body.reason = reason
595
+
596
+ return body
597
+
598
+
599
+ #: The codes that mean "this session will never work again without a human".
600
+ #: Distinguished from a transport failure because the supervisor must stop
601
+ #: reconnecting rather than back off — retrying an unregistered auth key is
602
+ #: how v1 span forever while reporting "degraded".
603
+ FATAL_AUTH_CODES = frozenset({"SESSION_ERROR", "AUTH_ERROR", "AUTH_PASSWORD_REQUIRED"})
604
+
605
+
606
+ def is_fatal_auth(exc: BaseException) -> bool:
607
+ """True when *exc* means the account needs a human to log in again."""
608
+ return rule_for(exc).code in FATAL_AUTH_CODES
609
+
610
+
611
+ def http_status_for(exc: BaseException) -> int:
612
+ """The HTTP status the daemon answers *exc* with."""
613
+ return rule_for(exc).http
614
+
615
+
616
+ def is_not_an_error(body: ErrorBody) -> bool:
617
+ """True for MESSAGE_NOT_MODIFIED, which is success wearing an exception."""
618
+ return body.code == NOT_MODIFIED
619
+
620
+
621
+ # ---------------------------------------------------------------------------
622
+ # Rendering
623
+ # ---------------------------------------------------------------------------
624
+
625
+
626
+ def exit_code_for(error: BaseException) -> int:
627
+ """Return the stable exit code for an error."""
628
+ if isinstance(error, TlgrError):
629
+ return error.exit_code
630
+ return EXIT_GENERIC
631
+
632
+
633
+ def error_body_dict(body: ErrorBody) -> dict[str, Any]:
634
+ """ErrorBody as JSON, plus v1's `error` key aliasing `message`.
635
+
636
+ Keeping the alias is what lets `--results-only` emit exactly the object v1
637
+ printed (`error`/`code`/`exit_code`) while the envelope carries the full
638
+ modern body (§12.4).
639
+ """
640
+ data: dict[str, Any] = msgspec.to_builtins(body)
641
+ data["error"] = body.message
642
+ return data
643
+
644
+
645
+ def error_envelope(
646
+ exc: BaseException, *, op: str = "", account: str | None = None, request_id: str = ""
647
+ ) -> dict[str, Any]:
648
+ """The v2 failure envelope: `{"ok": false, "op": …, "error": {…}}`."""
649
+ body = classify(exc, account=account, request_id=request_id)
650
+ envelope: dict[str, Any] = {"ok": False, "error": error_body_dict(body)}
651
+ if op:
652
+ envelope["op"] = op
653
+ if account:
654
+ envelope["account"] = account
655
+ return envelope
656
+
657
+
658
+ def format_error_json(error: BaseException) -> dict[str, Any]:
659
+ """v1's flat error object — kept verbatim as a compatibility contract.
660
+
661
+ This is the shape `--results-only` still prints, so every v1 consumer
662
+ that reads `error`/`code`/`exit_code` off stdout keeps working. New code
663
+ wants `error_envelope()`.
664
+ """
665
+ code = getattr(error, "code", "UNKNOWN_ERROR")
666
+ result: dict[str, Any] = {
667
+ "error": str(error),
668
+ "code": code,
669
+ "exit_code": exit_code_for(error),
670
+ }
671
+ if isinstance(error, RateLimitError) and error.wait_seconds:
672
+ result["wait_seconds"] = error.wait_seconds
673
+ return result
674
+
675
+
676
+ def emit_error(error: BaseException, use_json: bool = False) -> None:
677
+ """Emit an error to stderr (human) and optionally stdout (JSON)."""
678
+ if use_json:
679
+ json.dump(format_error_json(error), sys.stdout)
680
+ sys.stdout.write("\n")
681
+ sys.stdout.flush()
682
+
683
+ hint = getattr(error, "hint", "")
684
+ if hint:
685
+ print(f"Error: {error}\n {hint}", file=sys.stderr)
686
+ else:
687
+ print(f"Error: {error}", file=sys.stderr)